From ac26804e0041b2157d0f3dbebbc84d2cfd50c647 Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:06:43 +0800 Subject: [PATCH 1/8] fix: expose promise-returning pooled API types --- packages/comlink-worker-pool/src/index.ts | 64 ++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/comlink-worker-pool/src/index.ts b/packages/comlink-worker-pool/src/index.ts index 073a66c..633bef9 100644 --- a/packages/comlink-worker-pool/src/index.ts +++ b/packages/comlink-worker-pool/src/index.ts @@ -1 +1,63 @@ -export * from "./WorkerPool"; +import { + WorkerPool as WorkerPoolImplementation, + type WorkerPoolOptions, +} from "./WorkerPool"; + +type CallableProxy = { + // biome-ignore lint/suspicious/noExplicitAny: worker APIs may have arbitrary signatures + [K in keyof TProxy]: (...args: any[]) => unknown; +}; + +/** Promise-returning API exposed by WorkerPool.getApi(). */ +export type PooledApi> = { + [K in keyof TProxy as K extends string + ? K extends "then" + ? never + : K + : never]: TProxy[K] extends (...args: infer TArgs) => infer TResult + ? (...args: TArgs) => Promise> + : never; +}; + +/** Public WorkerPool instance with the scheduled API return type. */ +export type WorkerPool< + TProxy extends CallableProxy, + TTask extends { method: keyof TProxy; args: unknown[] } = { + method: keyof TProxy; + args: unknown[]; + }, + TResult = Awaited>, +> = Omit, "getApi"> & { + getApi(): PooledApi; +}; + +interface WorkerPoolConstructor { + new < + TProxy extends CallableProxy, + TTask extends { method: keyof TProxy; args: unknown[] } = { + method: keyof TProxy; + args: unknown[]; + }, + TResult = Awaited>, + >(options: WorkerPoolOptions): WorkerPool; +} + +export const WorkerPool = + WorkerPoolImplementation as unknown as WorkerPoolConstructor; + +export type { + QueueOverflowPolicy, + Task, + WorkerFactory, + WorkerPoolEvent, + WorkerPoolObserver, + WorkerPoolOptions, + WorkerPoolShutdownReport, + WorkerPoolState, + WorkerPoolStats, + WorkerPoolTaskOutcome, + WorkerPoolWorkerRemovalReason, + WorkerTaskOptions, + WorkerTerminator, +} from "./WorkerPool"; +export * from "./errors"; From a7f009275b6680655d506460c893539b2db876c8 Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:07:31 +0800 Subject: [PATCH 2/8] fix: propagate pooled API types through React --- .../src/useWorkerPool.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/comlink-worker-pool-react/src/useWorkerPool.ts b/packages/comlink-worker-pool-react/src/useWorkerPool.ts index 99816ee..c686d15 100644 --- a/packages/comlink-worker-pool-react/src/useWorkerPool.ts +++ b/packages/comlink-worker-pool-react/src/useWorkerPool.ts @@ -1,4 +1,5 @@ import { + type PooledApi, type WorkerFactory, WorkerPool, type WorkerPoolOptions, @@ -27,6 +28,13 @@ type CallableProxy = { [K in keyof TProxy]: (...args: any[]) => unknown; }; +type PooledMethod< + TProxy extends CallableProxy, + K extends keyof PooledApi, +> = PooledApi[K] extends (...args: infer TArgs) => infer TResult + ? (...args: TArgs) => TResult + : never; + /** Options for configuring useWorkerPool. */ export interface UseWorkerPoolOptions> { /** Creates a fresh Worker instance. */ @@ -81,8 +89,8 @@ export interface UseWorkerPoolOptions> { /** State returned from useWorkerPool. */ export interface UseWorkerPoolResult> { - /** Proxy API for direct calls, or null if initialization failed. */ - api: TProxy | null; + /** Scheduled proxy API for direct calls, or null if initialization failed. */ + api: PooledApi | null; /** Lifecycle of the owned pool, separate from the latest task status. */ poolStatus: "initializing" | "ready" | "error" | "closed"; /** State of the latest call started through call(). */ @@ -92,10 +100,10 @@ export interface UseWorkerPoolResult> { /** Error from the latest call or pool initialization. */ error: unknown; /** Invokes a method and tracks it as the latest call. */ - call( + call>( method: K, - ...args: Parameters - ): Promise>>; + ...args: Parameters> + ): Promise>>>; /** Immediately closes the owned pool; null means no pool was created. */ close(): Promise; } @@ -115,7 +123,7 @@ export function useWorkerPool>( >("idle"); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [api, setApi] = useState(null); + const [api, setApi] = useState | null>(null); const [poolStatus, setPoolStatus] = useState< "initializing" | "ready" | "error" | "closed" >("initializing"); @@ -260,10 +268,10 @@ export function useWorkerPool>( const callGeneration = generationRef.current; const call = useCallback( - async ( + async >( method: K, - ...args: Parameters - ): Promise>> => { + ...args: Parameters> + ): Promise>>> => { const bindingIsCurrent = () => activeCallBindingRef.current === callBinding && generationRef.current === callGeneration; @@ -292,12 +300,17 @@ export function useWorkerPool>( } try { - const value = await api[method](...args); + const task = api[method] as unknown as PooledMethod; + const value = (await Reflect.apply( + task, + api, + args, + )) as Awaited>>; if (isCurrent()) { setResult(() => value); setStatus("completed"); } - return value as Awaited>; + return value; } catch (callError) { if (isCurrent()) { setError(() => callError); From 302190718fc2b7a84c5a55916768f6ee2d654a46 Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:08:00 +0800 Subject: [PATCH 3/8] test: assert public pooled API declarations --- scripts/smoke-package-consumer.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-package-consumer.mjs b/scripts/smoke-package-consumer.mjs index 8ea5502..8c2223c 100644 --- a/scripts/smoke-package-consumer.mjs +++ b/scripts/smoke-package-consumer.mjs @@ -38,7 +38,7 @@ function writeConsumerFiles(directory) { ); writeFileSync( join(directory, "consumer.ts"), - 'import { WorkerPool, type WorkerPoolShutdownReport } from "comlink-worker-pool";\nimport { useWorkerPool, useWorkerTask } from "comlink-worker-pool-react";\ninterface Api { add(a: number, b: number): Promise }\ndeclare const workerFactory: () => Worker;\ndeclare const proxyFactory: (worker: Worker) => Api;\nconst pool = new WorkerPool({ size: 1, workerFactory, proxyFactory, maxQueueSize: 2 });\nconst result: Promise = pool.run("add", [1, 2], { priority: 1 });\nconst shutdown: Promise = pool.drain();\nconst hook = useWorkerPool({ workerFactory, proxyFactory, poolSize: 1 });\nconst task = useWorkerTask(hook.api, "add");\nconst taskResult: number | null = task.result;\nvoid result;\nvoid shutdown;\nvoid taskResult;\n', + 'import { WorkerPool, type PooledApi, type WorkerPoolShutdownReport } from "comlink-worker-pool";\nimport { useWorkerPool, useWorkerTask } from "comlink-worker-pool-react";\ninterface Api { add(a: number, b: number): Promise }\ninterface SyncApi { sync(value: number): number; then(): Promise; [Symbol.iterator](): Iterator }\ndeclare const workerFactory: () => Worker;\ndeclare const proxyFactory: (worker: Worker) => Api;\ndeclare const syncProxyFactory: (worker: Worker) => SyncApi;\nconst pool = new WorkerPool({ size: 1, workerFactory, proxyFactory, maxQueueSize: 2 });\nconst result: Promise = pool.run("add", [1, 2], { priority: 1 });\nconst shutdown: Promise = pool.drain();\nconst hook = useWorkerPool({ workerFactory, proxyFactory, poolSize: 1 });\nconst task = useWorkerTask(hook.api, "add");\nconst taskResult: number | null = task.result;\nconst syncPool = new WorkerPool({ size: 1, workerFactory, proxyFactory: syncProxyFactory });\nconst pooledApi: PooledApi = syncPool.getApi();\nconst syncResult: Promise = pooledApi.sync(1);\nconst reservedResult: Promise = syncPool.run("then", []);\n// @ts-expect-error Scheduled calls always return promises.\nconst incorrectSyncResult: number = pooledApi.sync(1);\n// @ts-expect-error The then key is reserved on the scheduled proxy.\npooledApi.then();\n// @ts-expect-error Symbol methods are not exposed by the scheduled proxy.\npooledApi[Symbol.iterator]();\nconst syncHook = useWorkerPool({ workerFactory, proxyFactory: syncProxyFactory });\nconst hookSyncResult: Promise | undefined = syncHook.api?.sync(1);\nconst trackedSyncResult: Promise = syncHook.call("sync", 1);\n// @ts-expect-error The then key is reserved on the scheduled hook API.\nsyncHook.call("then");\nvoid result;\nvoid shutdown;\nvoid taskResult;\nvoid syncResult;\nvoid reservedResult;\nvoid incorrectSyncResult;\nvoid hookSyncResult;\nvoid trackedSyncResult;\n', ); writeFileSync( join(directory, "tsconfig.json"), From 49eaa2e293adfba87d046b901bee664504e1d290 Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:08:08 +0800 Subject: [PATCH 4/8] chore: add pooled API typing changeset --- .changeset/typed-pools-return.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/typed-pools-return.md diff --git a/.changeset/typed-pools-return.md b/.changeset/typed-pools-return.md new file mode 100644 index 0000000..6104674 --- /dev/null +++ b/.changeset/typed-pools-return.md @@ -0,0 +1,6 @@ +--- +"comlink-worker-pool": patch +"comlink-worker-pool-react": patch +--- + +Correct the public scheduled API types so every pooled method returns a Promise, while reserved `then` and symbol keys are omitted from `getApi()` and the React hook API. From 7bbbb3d909bf48dc310e3a63a4d01e70afb0df6d Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:15:18 +0800 Subject: [PATCH 5/8] style: apply Biome formatting --- packages/comlink-worker-pool/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/comlink-worker-pool/src/index.ts b/packages/comlink-worker-pool/src/index.ts index 633bef9..70306ac 100644 --- a/packages/comlink-worker-pool/src/index.ts +++ b/packages/comlink-worker-pool/src/index.ts @@ -39,7 +39,9 @@ interface WorkerPoolConstructor { args: unknown[]; }, TResult = Awaited>, - >(options: WorkerPoolOptions): WorkerPool; + >( + options: WorkerPoolOptions, + ): WorkerPool; } export const WorkerPool = From 7e5bc0df573ec30f519df1eeb2ebeced3d8fa75e Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:17:16 +0800 Subject: [PATCH 6/8] style: apply Biome formatting --- packages/comlink-worker-pool-react/src/useWorkerPool.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/comlink-worker-pool-react/src/useWorkerPool.ts b/packages/comlink-worker-pool-react/src/useWorkerPool.ts index c686d15..c7cb9a7 100644 --- a/packages/comlink-worker-pool-react/src/useWorkerPool.ts +++ b/packages/comlink-worker-pool-react/src/useWorkerPool.ts @@ -301,11 +301,9 @@ export function useWorkerPool>( try { const task = api[method] as unknown as PooledMethod; - const value = (await Reflect.apply( - task, - api, - args, - )) as Awaited>>; + const value = (await Reflect.apply(task, api, args)) as Awaited< + ReturnType> + >; if (isCurrent()) { setResult(() => value); setStatus("completed"); From be7ee6dde4566a3f2e6f502bd4599dc641663bf5 Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:21:48 +0800 Subject: [PATCH 7/8] fix: reserve then on string-indexed pooled APIs --- packages/comlink-worker-pool/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/comlink-worker-pool/src/index.ts b/packages/comlink-worker-pool/src/index.ts index 70306ac..451071a 100644 --- a/packages/comlink-worker-pool/src/index.ts +++ b/packages/comlink-worker-pool/src/index.ts @@ -17,6 +17,9 @@ export type PooledApi> = { : never]: TProxy[K] extends (...args: infer TArgs) => infer TResult ? (...args: TArgs) => Promise> : never; +} & { + /** Reserved so string-indexed APIs cannot make the scheduling proxy thenable. */ + readonly then?: never; }; /** Public WorkerPool instance with the scheduled API return type. */ From 5127afe6b91bbe800dbcd7ad00813f3b3d2341be Mon Sep 17 00:00:00 2001 From: Natan <1708681+natanelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:22:24 +0800 Subject: [PATCH 8/8] test: cover reserved then on string-indexed APIs --- scripts/smoke-package-consumer.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-package-consumer.mjs b/scripts/smoke-package-consumer.mjs index 8c2223c..d5fd2a8 100644 --- a/scripts/smoke-package-consumer.mjs +++ b/scripts/smoke-package-consumer.mjs @@ -38,7 +38,7 @@ function writeConsumerFiles(directory) { ); writeFileSync( join(directory, "consumer.ts"), - 'import { WorkerPool, type PooledApi, type WorkerPoolShutdownReport } from "comlink-worker-pool";\nimport { useWorkerPool, useWorkerTask } from "comlink-worker-pool-react";\ninterface Api { add(a: number, b: number): Promise }\ninterface SyncApi { sync(value: number): number; then(): Promise; [Symbol.iterator](): Iterator }\ndeclare const workerFactory: () => Worker;\ndeclare const proxyFactory: (worker: Worker) => Api;\ndeclare const syncProxyFactory: (worker: Worker) => SyncApi;\nconst pool = new WorkerPool({ size: 1, workerFactory, proxyFactory, maxQueueSize: 2 });\nconst result: Promise = pool.run("add", [1, 2], { priority: 1 });\nconst shutdown: Promise = pool.drain();\nconst hook = useWorkerPool({ workerFactory, proxyFactory, poolSize: 1 });\nconst task = useWorkerTask(hook.api, "add");\nconst taskResult: number | null = task.result;\nconst syncPool = new WorkerPool({ size: 1, workerFactory, proxyFactory: syncProxyFactory });\nconst pooledApi: PooledApi = syncPool.getApi();\nconst syncResult: Promise = pooledApi.sync(1);\nconst reservedResult: Promise = syncPool.run("then", []);\n// @ts-expect-error Scheduled calls always return promises.\nconst incorrectSyncResult: number = pooledApi.sync(1);\n// @ts-expect-error The then key is reserved on the scheduled proxy.\npooledApi.then();\n// @ts-expect-error Symbol methods are not exposed by the scheduled proxy.\npooledApi[Symbol.iterator]();\nconst syncHook = useWorkerPool({ workerFactory, proxyFactory: syncProxyFactory });\nconst hookSyncResult: Promise | undefined = syncHook.api?.sync(1);\nconst trackedSyncResult: Promise = syncHook.call("sync", 1);\n// @ts-expect-error The then key is reserved on the scheduled hook API.\nsyncHook.call("then");\nvoid result;\nvoid shutdown;\nvoid taskResult;\nvoid syncResult;\nvoid reservedResult;\nvoid incorrectSyncResult;\nvoid hookSyncResult;\nvoid trackedSyncResult;\n', + 'import { WorkerPool, type PooledApi, type WorkerPoolShutdownReport } from "comlink-worker-pool";\nimport { useWorkerPool, useWorkerTask } from "comlink-worker-pool-react";\ninterface Api { add(a: number, b: number): Promise }\ninterface SyncApi { sync(value: number): number; then(): Promise; [Symbol.iterator](): Iterator }\ninterface StringIndexedApi { [method: string]: () => number }\ndeclare const workerFactory: () => Worker;\ndeclare const proxyFactory: (worker: Worker) => Api;\ndeclare const syncProxyFactory: (worker: Worker) => SyncApi;\ndeclare const stringIndexedProxyFactory: (worker: Worker) => StringIndexedApi;\nconst pool = new WorkerPool({ size: 1, workerFactory, proxyFactory, maxQueueSize: 2 });\nconst result: Promise = pool.run("add", [1, 2], { priority: 1 });\nconst shutdown: Promise = pool.drain();\nconst hook = useWorkerPool({ workerFactory, proxyFactory, poolSize: 1 });\nconst task = useWorkerTask(hook.api, "add");\nconst taskResult: number | null = task.result;\nconst syncPool = new WorkerPool({ size: 1, workerFactory, proxyFactory: syncProxyFactory });\nconst pooledApi: PooledApi = syncPool.getApi();\nconst syncResult: Promise = pooledApi.sync(1);\nconst reservedResult: Promise = syncPool.run("then", []);\n// @ts-expect-error Scheduled calls always return promises.\nconst incorrectSyncResult: number = pooledApi.sync(1);\n// @ts-expect-error The then key is reserved on the scheduled proxy.\npooledApi.then();\n// @ts-expect-error Symbol methods are not exposed by the scheduled proxy.\npooledApi[Symbol.iterator]();\nconst syncHook = useWorkerPool({ workerFactory, proxyFactory: syncProxyFactory });\nconst hookSyncResult: Promise | undefined = syncHook.api?.sync(1);\nconst trackedSyncResult: Promise = syncHook.call("sync", 1);\n// @ts-expect-error The then key is reserved on the scheduled hook API.\nsyncHook.call("then");\nconst stringIndexedPool = new WorkerPool({ size: 1, workerFactory, proxyFactory: stringIndexedProxyFactory });\nconst stringIndexedApi: PooledApi = stringIndexedPool.getApi();\nconst stringIndexedResult: Promise = stringIndexedApi.work();\n// @ts-expect-error The then key stays reserved for string-indexed scheduled APIs.\nstringIndexedApi.then();\nconst stringIndexedHook = useWorkerPool({ workerFactory, proxyFactory: stringIndexedProxyFactory });\nconst stringIndexedHookResult: Promise | undefined = stringIndexedHook.api?.work();\nconst stringIndexedTrackedResult: Promise = stringIndexedHook.call("work");\n// @ts-expect-error Tracked calls also reserve then for string-indexed APIs.\nstringIndexedHook.call("then");\nvoid result;\nvoid shutdown;\nvoid taskResult;\nvoid syncResult;\nvoid reservedResult;\nvoid incorrectSyncResult;\nvoid hookSyncResult;\nvoid trackedSyncResult;\nvoid stringIndexedResult;\nvoid stringIndexedHookResult;\nvoid stringIndexedTrackedResult;\n', ); writeFileSync( join(directory, "tsconfig.json"),