From f12861a686e880b48754c7606bfb5b87b7031089 Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Thu, 5 Feb 2026 15:47:28 +0100 Subject: [PATCH 1/8] init implementation done --- packages/transformers-webworker/.gitignore | 6 + packages/transformers-webworker/README.md | 70 +++++++ .../transformers-webworker/jest.config.mjs | 35 ++++ packages/transformers-webworker/package.json | 60 ++++++ .../transformers-webworker/scripts/build.mjs | 25 +++ .../scripts/build/buildAll.mjs | 44 ++++ .../scripts/build/buildAllWithWatch.mjs | 44 ++++ .../scripts/build/constants.mjs | 25 +++ .../scripts/build/targets.mjs | 17 ++ .../transformers-webworker/scripts/dev.mjs | 113 +++++++++++ .../transformers-webworker/src/constants.ts | 4 + packages/transformers-webworker/src/index.ts | 18 ++ .../src/webWorkerPipeline.ts | 81 ++++++++ .../src/webWorkerPipelineHandler.ts | 46 +++++ .../tests/temp_constants.txt | 3 + .../tests/webWorkerPipeline.test.js | 189 ++++++++++++++++++ .../tests/webWorkerPipelineHandler.test.js | 135 +++++++++++++ packages/transformers-webworker/tsconfig.json | 24 +++ pnpm-lock.yaml | 21 ++ 19 files changed, 960 insertions(+) create mode 100644 packages/transformers-webworker/.gitignore create mode 100644 packages/transformers-webworker/README.md create mode 100644 packages/transformers-webworker/jest.config.mjs create mode 100644 packages/transformers-webworker/package.json create mode 100644 packages/transformers-webworker/scripts/build.mjs create mode 100644 packages/transformers-webworker/scripts/build/buildAll.mjs create mode 100644 packages/transformers-webworker/scripts/build/buildAllWithWatch.mjs create mode 100644 packages/transformers-webworker/scripts/build/constants.mjs create mode 100644 packages/transformers-webworker/scripts/build/targets.mjs create mode 100644 packages/transformers-webworker/scripts/dev.mjs create mode 100644 packages/transformers-webworker/src/constants.ts create mode 100644 packages/transformers-webworker/src/index.ts create mode 100644 packages/transformers-webworker/src/webWorkerPipeline.ts create mode 100644 packages/transformers-webworker/src/webWorkerPipelineHandler.ts create mode 100644 packages/transformers-webworker/tests/temp_constants.txt create mode 100644 packages/transformers-webworker/tests/webWorkerPipeline.test.js create mode 100644 packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js create mode 100644 packages/transformers-webworker/tsconfig.json diff --git a/packages/transformers-webworker/.gitignore b/packages/transformers-webworker/.gitignore new file mode 100644 index 000000000..726045f98 --- /dev/null +++ b/packages/transformers-webworker/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +types +coverage +*.log +.DS_Store diff --git a/packages/transformers-webworker/README.md b/packages/transformers-webworker/README.md new file mode 100644 index 000000000..4a7b86c24 --- /dev/null +++ b/packages/transformers-webworker/README.md @@ -0,0 +1,70 @@ +# @huggingface/transformers-webworker + +Web Worker utilities for Transformers.js - a lightweight package designed to be used in Web Workers. + +## Installation + +You need to install both this package and the main transformers package: + +```bash +npm install @huggingface/transformers @huggingface/transformers-webworker +``` + +Or with pnpm: + +```bash +pnpm add @huggingface/transformers @huggingface/transformers-webworker +``` + +## Usage + +This package provides utilities for using Transformers.js pipelines in Web Workers. + +### In Your Main Thread + +Use `webWorkerPipeline` to communicate with a Web Worker running a pipeline: + +```typescript +import { webWorkerPipeline } from '@huggingface/transformers-webworker'; + +// Create your worker +const worker = new Worker('./worker.js'); + +// Initialize the pipeline +const classifier = await webWorkerPipeline( + worker, + 'sentiment-analysis', + 'Xenova/distilbert-base-uncased-finetuned-sst-2-english' +); + +// Use it like a regular pipeline +const result = await classifier('I love this!'); +console.log(result); +``` + +### In Your Web Worker + +Use `webWorkerPipelineHandler` to handle pipeline requests: + +```typescript +// worker.js +import { webWorkerPipelineHandler } from '@huggingface/transformers-webworker'; + +const handler = webWorkerPipelineHandler(); +self.onmessage = handler.onmessage; +``` + +> **Note:** The handler internally uses `pipeline` from `@huggingface/transformers` to create and cache pipeline instances. + +## Development + +```bash +# Install dependencies +pnpm install + +# Build the package +pnpm build + +# Development mode with watch +pnpm dev +``` diff --git a/packages/transformers-webworker/jest.config.mjs b/packages/transformers-webworker/jest.config.mjs new file mode 100644 index 000000000..62e158aaf --- /dev/null +++ b/packages/transformers-webworker/jest.config.mjs @@ -0,0 +1,35 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +export default { + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ["node_modules", "tests"], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of paths to directories that Jest should use to search for files in + roots: ["./tests/"], + + // A map from regular expressions to paths to transformers + transform: {}, + + // Module name mapper to resolve TypeScript files + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + + // Extensions to resolve + moduleFileExtensions: ["js", "ts", "mjs"], +}; diff --git a/packages/transformers-webworker/package.json b/packages/transformers-webworker/package.json new file mode 100644 index 000000000..8c87190a8 --- /dev/null +++ b/packages/transformers-webworker/package.json @@ -0,0 +1,60 @@ +{ + "name": "@huggingface/transformers-webworker", + "version": "0.0.1", + "description": "Web Worker utilities for Transformers.js", + "main": "./dist/index.js", + "types": "./types/src/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./types/src/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "format": "prettier --write . --ignore-path ../../.prettierignore", + "format:check": "prettier --check . --ignore-path ../../.prettierignore", + "typegen": "tsc --build", + "dev": "node scripts/dev.mjs", + "build": "node scripts/build.mjs && pnpm typegen", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/huggingface/transformers.js.git" + }, + "keywords": [ + "transformers", + "transformers.js", + "webworker", + "huggingface", + "machine learning" + ], + "author": "Hugging Face", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/huggingface/transformers.js/issues" + }, + "homepage": "https://github.com/huggingface/transformers.js#readme", + "peerDependencies": { + "@huggingface/transformers": "^4.0.0" + }, + "devDependencies": { + "@huggingface/transformers": "workspace:*", + "@types/jest": "^30.0.0", + "esbuild": "^0.27.2", + "jest": "^30.2.0", + "jest-environment-node": "^30.2.0", + "typescript": "5.9.3" + }, + "files": [ + "src", + "dist", + "types", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/transformers-webworker/scripts/build.mjs b/packages/transformers-webworker/scripts/build.mjs new file mode 100644 index 000000000..9c7f3a08c --- /dev/null +++ b/packages/transformers-webworker/scripts/build.mjs @@ -0,0 +1,25 @@ +import { OUT_DIR } from "./build/constants.mjs"; +import prepareOutDir from "../../../scripts/prepareOutDir.mjs"; +import { colors, createLogger } from "../../../scripts/logger.mjs"; +import { buildAll } from "./build/buildAll.mjs"; + +const log = createLogger("transformers-webworker"); + +log.section("BUILD"); +log.info("Building transformers-webworker with esbuild...\n"); + +const startTime = performance.now(); + +try { + prepareOutDir(OUT_DIR); + + await buildAll(log); + + const endTime = performance.now(); + const duration = (endTime - startTime).toFixed(2); + log.success(`All builds completed in ${colors.bright}${duration}ms${colors.reset}\n`); +} catch (error) { + log.error(`Build failed: ${error.message}`); + console.error(error); + process.exit(1); +} diff --git a/packages/transformers-webworker/scripts/build/buildAll.mjs b/packages/transformers-webworker/scripts/build/buildAll.mjs new file mode 100644 index 000000000..3d57ba775 --- /dev/null +++ b/packages/transformers-webworker/scripts/build/buildAll.mjs @@ -0,0 +1,44 @@ +import { build as esbuild } from "esbuild"; +import path from "node:path"; +import { OUT_DIR, ROOT_DIR, getEsbuildProdConfig } from "./constants.mjs"; +import { reportSize } from "../../../../scripts/reportSize.mjs"; +import { colors } from "../../../../scripts/logger.mjs"; +import { BUILD_TARGETS } from "./targets.mjs"; + +/** + * Helper function to create build configurations. + */ +async function buildTarget({ name = "", suffix = ".js", format = "esm", externalModules = [] }, log) { + const regularFile = `index${name}${suffix}`; + const minFile = `index${name}.min${suffix}`; + + log.build(`Building ${colors.bright}${regularFile}${colors.reset}...`); + await esbuild({ + ...getEsbuildProdConfig(ROOT_DIR), + format, + outfile: path.join(OUT_DIR, regularFile), + external: externalModules, + }); + reportSize(path.join(OUT_DIR, regularFile), log); + + log.build(`Building ${colors.bright}${minFile}${colors.reset}...`); + await esbuild({ + ...getEsbuildProdConfig(ROOT_DIR), + format, + outfile: path.join(OUT_DIR, minFile), + minify: true, + external: externalModules, + legalComments: "none", + }); + reportSize(path.join(OUT_DIR, minFile), log); +} + +/** + * Build all targets for production + */ +export async function buildAll(log) { + for (const target of BUILD_TARGETS) { + log.section(target.name); + await buildTarget(target.config, log); + } +} diff --git a/packages/transformers-webworker/scripts/build/buildAllWithWatch.mjs b/packages/transformers-webworker/scripts/build/buildAllWithWatch.mjs new file mode 100644 index 000000000..9993a50e0 --- /dev/null +++ b/packages/transformers-webworker/scripts/build/buildAllWithWatch.mjs @@ -0,0 +1,44 @@ +import { context } from "esbuild"; +import path from "node:path"; +import { rebuildPlugin } from "../../../../scripts/rebuildPlugin.mjs"; +import { OUT_DIR, ROOT_DIR, getEsbuildDevConfig } from "./constants.mjs"; +import { BUILD_TARGETS } from "./targets.mjs"; + +/** + * Create an esbuild context for a single build target + */ +async function createBuildContext(targetName, targetConfig, log) { + const { name, suffix, format, externalModules } = targetConfig; + const outputFile = `index${name}${suffix}`; + + const plugins = [rebuildPlugin(targetName, log)]; + + return context({ + ...getEsbuildDevConfig(ROOT_DIR), + format, + outfile: path.join(OUT_DIR, outputFile), + external: externalModules, + plugins, + }); +} + +/** + * Build all targets in watch mode for development + * @returns {Promise} Array of esbuild contexts that can be disposed later + */ +export async function buildAllWithWatch(log) { + log.dim("Creating build contexts...\n"); + + // Create contexts for all targets + const contexts = await Promise.all(BUILD_TARGETS.map((target) => createBuildContext(target.name, target.config, log))); + + log.dim("Starting initial build...\n"); + + // Wait for the initial builds to complete + await Promise.all(contexts.map((ctx) => ctx.rebuild())); + + // Start watching all targets + await Promise.all(contexts.map((ctx) => ctx.watch())); + + return contexts; +} diff --git a/packages/transformers-webworker/scripts/build/constants.mjs b/packages/transformers-webworker/scripts/build/constants.mjs new file mode 100644 index 000000000..f21dd64df --- /dev/null +++ b/packages/transformers-webworker/scripts/build/constants.mjs @@ -0,0 +1,25 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DIST_FOLDER = "dist"; +export const EXTERNAL_MODULES = ["@huggingface/transformers"]; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const ROOT_DIR = path.join(__dirname, "../.."); +export const OUT_DIR = path.join(ROOT_DIR, DIST_FOLDER); + +export const getEsbuildDevConfig = (rootDir) => ({ + bundle: true, + treeShaking: true, + logLevel: "info", + entryPoints: [path.join(rootDir, "src/index.ts")], + platform: "browser", + format: "esm", + sourcemap: true, +}); + +export const getEsbuildProdConfig = (rootDir) => ({ + ...getEsbuildDevConfig(rootDir), + logLevel: "warning", + sourcemap: false, +}); diff --git a/packages/transformers-webworker/scripts/build/targets.mjs b/packages/transformers-webworker/scripts/build/targets.mjs new file mode 100644 index 000000000..ce08131dd --- /dev/null +++ b/packages/transformers-webworker/scripts/build/targets.mjs @@ -0,0 +1,17 @@ +import { EXTERNAL_MODULES } from "./constants.mjs"; + +/** + * Build target configuration + * Each target defines a specific build variant + */ +export const BUILD_TARGETS = [ + { + name: "Web Worker Build (ESM)", + config: { + name: "", + suffix: ".js", + format: "esm", + externalModules: EXTERNAL_MODULES, + }, + }, +]; diff --git a/packages/transformers-webworker/scripts/dev.mjs b/packages/transformers-webworker/scripts/dev.mjs new file mode 100644 index 000000000..931718c5c --- /dev/null +++ b/packages/transformers-webworker/scripts/dev.mjs @@ -0,0 +1,113 @@ +import { spawn } from "node:child_process"; +import { unlinkSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { OUT_DIR } from "./build/constants.mjs"; +import prepareOutDir from "../../../scripts/prepareOutDir.mjs"; +import { colors, createLogger } from "../../../scripts/logger.mjs"; +import { buildAllWithWatch } from "./build/buildAllWithWatch.mjs"; + +const log = createLogger("transformers-webworker"); +const startTime = performance.now(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.join(__dirname, ".."); + +prepareOutDir(OUT_DIR); + +// Remove tsbuildinfo to force TypeScript to rebuild type declarations +try { + unlinkSync(`${ROOT_DIR}/types/tsconfig.tsbuildinfo`); +} catch (err) { + // File doesn't exist, that's fine +} + +log.section("BUILD"); +log.info("Building transformers-webworker with esbuild in watch mode..."); + +// Build all targets with watch mode +const contexts = await buildAllWithWatch(log); + +const endTime = performance.now(); +const duration = (endTime - startTime).toFixed(2); +log.success(`All builds completed in ${colors.bright}${duration}ms${colors.reset}`); + +// Generate initial TypeScript declarations, then start watch mode +log.section("TYPES"); +log.info("Generating initial type declarations..."); + +await new Promise((resolve, reject) => { + const tscBuild = spawn("tsc", ["--build"], { + cwd: ROOT_DIR, + stdio: "pipe", + shell: true, + }); + + tscBuild.stdout.on("data", (data) => { + const output = data.toString().trim(); + if (output && output.includes("error")) { + log.dim(output); + } + }); + + tscBuild.stderr.on("data", (data) => { + const output = data.toString().trim(); + if (output) { + log.error(output); + } + }); + + tscBuild.on("exit", (code) => { + if (code === 0) { + log.done("Type declarations generated"); + resolve(); + } else { + reject(new Error(`TypeScript build failed with code ${code}`)); + } + }); +}); + +log.info("Starting TypeScript watch mode...\n"); + +const tscWatch = spawn("tsc", ["--build", "--watch", "--preserveWatchOutput"], { + cwd: ROOT_DIR, + stdio: "pipe", + shell: true, +}); + +tscWatch.stdout.on("data", (data) => { + const output = data.toString().trim(); + if (output) { + output.split("\n").forEach((line) => { + // Filter out verbose output, only show important messages + if ( + line.includes("error") || + line.includes("File change detected") || + line.includes("Found 0 errors") || + (line.includes("Found") && line.includes("error")) + ) { + log.dim(line); + } + }); + } +}); + +tscWatch.stderr.on("data", (data) => { + const output = data.toString().trim(); + if (output) { + output.split("\n").forEach((line) => { + log.error(line); + }); + } +}); + +log.dim(`Watching for changes...\n`); + +// Keep process alive and cleanup +process.on("SIGINT", async () => { + log.warning(`\nStopping watch mode...`); + tscWatch.kill(); + await Promise.all(contexts.map((ctx) => ctx.dispose())); + log.dim("Goodbye!"); + process.exit(0); +}); diff --git a/packages/transformers-webworker/src/constants.ts b/packages/transformers-webworker/src/constants.ts new file mode 100644 index 000000000..4367e66b7 --- /dev/null +++ b/packages/transformers-webworker/src/constants.ts @@ -0,0 +1,4 @@ +export const REQUEST_MESSAGE_TYPE = 'transformersjs_worker_pipeline'; +export const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = 'transformersjs_worker_invokeCallback'; +export const RESPONSE_MESSAGE_TYPE_READY = 'transformersjs_worker_ready'; +export const RESPONSE_MESSAGE_TYPE_RESULT = 'transformersjs_worker_result'; diff --git a/packages/transformers-webworker/src/index.ts b/packages/transformers-webworker/src/index.ts new file mode 100644 index 000000000..b0b5e8974 --- /dev/null +++ b/packages/transformers-webworker/src/index.ts @@ -0,0 +1,18 @@ +/** + * @file Entry point for the Transformers.js Web Worker utilities. + * Provides helper functions for using Transformers.js pipelines in Web Workers. + * + * @module transformers-webworker + */ + +// Web Worker utilities +export { default as webWorkerPipeline } from './webWorkerPipeline.js'; +export { default as webWorkerPipelineHandler } from './webWorkerPipelineHandler.js'; + +// Constants +export { + REQUEST_MESSAGE_TYPE, + RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + RESPONSE_MESSAGE_TYPE_READY, + RESPONSE_MESSAGE_TYPE_RESULT, +} from './constants.js'; diff --git a/packages/transformers-webworker/src/webWorkerPipeline.ts b/packages/transformers-webworker/src/webWorkerPipeline.ts new file mode 100644 index 000000000..c01852de9 --- /dev/null +++ b/packages/transformers-webworker/src/webWorkerPipeline.ts @@ -0,0 +1,81 @@ +import { PipelineType } from '@huggingface/transformers'; +import { + REQUEST_MESSAGE_TYPE, + RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + RESPONSE_MESSAGE_TYPE_RESULT, +} from './constants.js'; + +const webWorkerPipeline = ( + worker: Worker, + task: PipelineType, + model_id: string, + options: Record = {}, +) => + new Promise((resolve, reject) => { + const callbackMap = new Map(); + + const messagesResolversMap = new Map(); + let messageIdCounter = 0; + + const serializeOptions = (options: Record) => { + const out: Record = {}; + Object.entries(options ?? {}).forEach(([key, value]) => { + if (typeof value === 'function') { + const functionId = `cb_${key}`; + callbackMap.set(functionId, value); + out[key] = { __fn: true, functionId }; + } else { + out[key] = value; + } + }); + return out; + }; + + worker.onmessage = (e) => { + const msg = e.data; + if (msg?.type === RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK) { + const { functionId, args } = msg; + const fn = callbackMap.get(functionId); + if (fn) { + fn(...args); + } + } + if (msg?.type === RESPONSE_MESSAGE_TYPE_RESULT) { + if (msg?.id === 'init') { + resolve((data: PayloadType, pipeOptions: Record) => { + return new Promise((resolve, reject) => { + const id = messageIdCounter++; + messagesResolversMap.set(id, { resolve, reject }); + worker.postMessage({ + id, + type: REQUEST_MESSAGE_TYPE, + data, + task, + model_id, + options: options ? serializeOptions(options) : {}, + pipeOptions, + }); + }); + }); + } else { + const resolver = messagesResolversMap.get(msg.id); + if (resolver) { + if (msg.error) resolver.reject(msg.error); + else resolver.resolve(msg.result); + messagesResolversMap.delete(msg.id); + } + } + } + }; + + messagesResolversMap.set('init', { resolve, reject }); + worker.postMessage({ + id: 'init', + type: REQUEST_MESSAGE_TYPE, + data: null, + task: task ?? '', + model_id: model_id ?? '', + options: options ? serializeOptions(options) : {}, + }); + }); +export default webWorkerPipeline; diff --git a/packages/transformers-webworker/src/webWorkerPipelineHandler.ts b/packages/transformers-webworker/src/webWorkerPipelineHandler.ts new file mode 100644 index 000000000..518f3c233 --- /dev/null +++ b/packages/transformers-webworker/src/webWorkerPipelineHandler.ts @@ -0,0 +1,46 @@ +import { pipeline } from '@huggingface/transformers'; +import { + REQUEST_MESSAGE_TYPE, + RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + RESPONSE_MESSAGE_TYPE_READY, + RESPONSE_MESSAGE_TYPE_RESULT, +} from './constants.js'; + +const pipelines = new Map(); + +const webWorkerPipelineHandler = () => { + const unserializeOptions = (options: Record) => { + const out: Record = {}; + Object.entries(options ?? {}).forEach(([key, value]) => { + if (typeof value === 'object' && value && '__fn' in value && value.__fn) { + out[key] = (...args: Array) => + self.postMessage({ + type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + functionId: 'functionId' in value ? value.functionId : null, + args, + }); + } else { + out[key] = value; + } + }); + return out; + }; + + return { + onmessage: async (event: MessageEvent) => { + if (!event?.data || event.data?.type !== REQUEST_MESSAGE_TYPE) return; + const { id, data, task, model_id, options, pipeOptions = {} } = event.data; + const key = JSON.stringify({ task, model_id, options }); + let pipe = pipelines.get(key); + if (!pipe) { + pipe = await pipeline(task, model_id, unserializeOptions(options)); + pipelines.set(key, pipe); + } + self.postMessage({ id, type: RESPONSE_MESSAGE_TYPE_READY }); + const result = data ? await pipe(data, pipeOptions) : null; + self.postMessage({ id, type: RESPONSE_MESSAGE_TYPE_RESULT, result }); + }, + }; +}; + +export default webWorkerPipelineHandler; diff --git a/packages/transformers-webworker/tests/temp_constants.txt b/packages/transformers-webworker/tests/temp_constants.txt new file mode 100644 index 000000000..31d3e9f7e --- /dev/null +++ b/packages/transformers-webworker/tests/temp_constants.txt @@ -0,0 +1,3 @@ +const REQUEST_MESSAGE_TYPE = 'transformersjs_worker_pipeline'; +const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = 'transformersjs_worker_invokeCallback'; +const RESPONSE_MESSAGE_TYPE_RESULT = 'transformersjs_worker_result'; diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.js b/packages/transformers-webworker/tests/webWorkerPipeline.test.js new file mode 100644 index 000000000..c5f415c4b --- /dev/null +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +// Import constants to match the implementation +const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; +const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; +const RESPONSE_MESSAGE_TYPE_RESULT = "transformersjs_worker_result"; + +describe("webWorkerPipeline", () => { + let webWorkerPipeline; + let mockWorker; + + beforeEach(async () => { + // Import the built module + const module = await import("../dist/index.js"); + webWorkerPipeline = module.webWorkerPipeline; + + // Create a mock Worker + mockWorker = { + postMessage: jest.fn(), + onmessage: null, + }; + }); + + it("should create a pipeline function", async () => { + // Setup the worker to respond + setTimeout(() => { + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + }, 0); + + const pipeline = await webWorkerPipeline(mockWorker, "sentiment-analysis", "test-model"); + + expect(typeof pipeline).toBe("function"); + expect(mockWorker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + id: "init", + type: REQUEST_MESSAGE_TYPE, + task: "sentiment-analysis", + model_id: "test-model", + }), + ); + }); + + it("should send initialization message with correct parameters", async () => { + const task = "text-classification"; + const modelId = "my-model"; + const options = { device: "cpu" }; + + setTimeout(() => { + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + }, 0); + + await webWorkerPipeline(mockWorker, task, modelId, options); + + expect(mockWorker.postMessage).toHaveBeenCalledWith({ + id: "init", + type: REQUEST_MESSAGE_TYPE, + data: null, + task: task, + model_id: modelId, + options: options, + }); + }); + + it("should serialize function options", async () => { + const callback = jest.fn(); + const options = { + progress_callback: callback, + device: "cpu", + }; + + setTimeout(() => { + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + }, 0); + + await webWorkerPipeline(mockWorker, "test-task", "test-model", options); + + const callArgs = mockWorker.postMessage.mock.calls[0][0]; + expect(callArgs.options.progress_callback).toEqual({ + __fn: true, + functionId: "cb_progress_callback", + }); + expect(callArgs.options.device).toBe("cpu"); + }); + + it("should handle callback invocations from worker", async () => { + const callback = jest.fn(); + const options = { + progress_callback: callback, + }; + + setTimeout(() => { + // First, send init response + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + + // Then simulate callback invocation + setTimeout(() => { + mockWorker.onmessage({ + data: { + type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + functionId: "cb_progress_callback", + args: [{ status: "progress", progress: 50 }], + }, + }); + }, 10); + }, 0); + + await webWorkerPipeline(mockWorker, "test-task", "test-model", options); + + // Wait for callback to be invoked + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(callback).toHaveBeenCalledWith({ status: "progress", progress: 50 }); + }); + + it("should send pipeline execution messages", async () => { + setTimeout(() => { + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + }, 0); + + const pipeline = await webWorkerPipeline(mockWorker, "sentiment-analysis", "test-model"); + + // Reset mock to clear init call + mockWorker.postMessage.mockClear(); + + // Simulate execution + const executePromise = pipeline("I love this!", { top_k: 1 }); + + // Simulate worker response + setTimeout(() => { + mockWorker.onmessage({ + data: { + id: 0, + type: RESPONSE_MESSAGE_TYPE_RESULT, + result: [{ label: "POSITIVE", score: 0.99 }], + }, + }); + }, 10); + + const result = await executePromise; + + expect(mockWorker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + id: 0, + type: REQUEST_MESSAGE_TYPE, + data: "I love this!", + task: "sentiment-analysis", + model_id: "test-model", + pipeOptions: { top_k: 1 }, + }), + ); + + expect(result).toEqual([{ label: "POSITIVE", score: 0.99 }]); + }); + + it("should handle errors from worker", async () => { + setTimeout(() => { + mockWorker.onmessage({ + data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, + }); + }, 0); + + const pipeline = await webWorkerPipeline(mockWorker, "test-task", "test-model"); + + const executePromise = pipeline("test input"); + + // Simulate worker error + setTimeout(() => { + mockWorker.onmessage({ + data: { + id: 0, + type: RESPONSE_MESSAGE_TYPE_RESULT, + error: new Error("Pipeline execution failed"), + }, + }); + }, 10); + + await expect(executePromise).rejects.toThrow("Pipeline execution failed"); + }); +}); diff --git a/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js new file mode 100644 index 000000000..9327da444 --- /dev/null +++ b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js @@ -0,0 +1,135 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +// Import constants to match the implementation +const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; +const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; + +describe("webWorkerPipelineHandler", () => { + let webWorkerPipelineHandler; + let handler; + let mockSelf; + + beforeEach(async () => { + // Clear all mocks + jest.clearAllMocks(); + + // Mock self.postMessage + mockSelf = { + postMessage: jest.fn(), + }; + global.self = mockSelf; + + // Import the built module + const module = await import("../dist/index.js"); + webWorkerPipelineHandler = module.webWorkerPipelineHandler; + handler = webWorkerPipelineHandler(); + }); + + it("should create a handler with onmessage function", () => { + expect(handler).toHaveProperty("onmessage"); + expect(typeof handler.onmessage).toBe("function"); + }); + + it("should ignore messages without correct type", async () => { + await handler.onmessage({ data: { type: "other" } }); + expect(mockSelf.postMessage).not.toHaveBeenCalled(); + }); + + it("should ignore messages without data", async () => { + await handler.onmessage({}); + expect(mockSelf.postMessage).not.toHaveBeenCalled(); + }); + + it("should return a promise when handling messages", () => { + const messageEvent = { + data: { + id: 1, + type: REQUEST_MESSAGE_TYPE, + task: "text-classification", + model_id: "test-model", + options: {}, + data: null, + }, + }; + + // The handler returns a promise (don't await to avoid loading real models) + const result = handler.onmessage(messageEvent); + expect(result instanceof Promise).toBe(true); + }); + + it("should unserialize function callbacks correctly", () => { + // Test the unserialize logic by checking what the handler would do + const serializedOptions = { + progress_callback: { + __fn: true, + functionId: "cb_progress_callback", + }, + device: "cpu", + }; + + // We can't directly test the internal unserializeOptions function, + // but we can test that serialized functions have the expected structure + expect(serializedOptions.progress_callback).toHaveProperty("__fn", true); + expect(serializedOptions.progress_callback).toHaveProperty("functionId"); + }); + + it("should handle callback invocation structure", () => { + // Simulate what would happen when a callback is invoked + const callbackFn = (...args) => + mockSelf.postMessage({ + type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + functionId: "test_callback_id", + args, + }); + + // Call the callback + callbackFn({ progress: 50 }); + + // Verify postMessage was called with correct structure + expect(mockSelf.postMessage).toHaveBeenCalledWith({ + type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, + functionId: "test_callback_id", + args: [{ progress: 50 }], + }); + }); + + it("should handle empty pipeOptions parameter", () => { + const messageWithoutPipeOptions = { + data: { + id: 1, + type: REQUEST_MESSAGE_TYPE, + task: "text-classification", + model_id: "test-model", + options: {}, + data: "test input", + // pipeOptions omitted + }, + }; + + // Should not throw synchronously when pipeOptions is undefined + expect(() => { + handler.onmessage(messageWithoutPipeOptions); + }).not.toThrow(); + }); + + it("should properly structure message data", () => { + const validMessage = { + data: { + id: 123, + type: REQUEST_MESSAGE_TYPE, + task: "text-classification", + model_id: "my-model", + options: { device: "webgpu" }, + data: "test input", + pipeOptions: { top_k: 5 }, + }, + }; + + // Verify message structure is valid + expect(validMessage.data).toHaveProperty("id"); + expect(validMessage.data).toHaveProperty("type", REQUEST_MESSAGE_TYPE); + expect(validMessage.data).toHaveProperty("task"); + expect(validMessage.data).toHaveProperty("model_id"); + expect(validMessage.data).toHaveProperty("data"); + }); +}); diff --git a/packages/transformers-webworker/tsconfig.json b/packages/transformers-webworker/tsconfig.json new file mode 100644 index 000000000..800662fbe --- /dev/null +++ b/packages/transformers-webworker/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "types", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "composite": true, + "lib": ["esnext", "webworker"], + "baseUrl": ".", + "paths": { + "@huggingface/transformers": ["../transformers/types/src/transformers"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../transformers" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 293fd76f5..6b84269c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,27 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/transformers-webworker: + devDependencies: + '@huggingface/transformers': + specifier: workspace:* + version: link:../transformers + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + esbuild: + specifier: ^0.27.2 + version: 0.27.2 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@24.10.9) + jest-environment-node: + specifier: ^30.2.0 + version: 30.2.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages: '@babel/code-frame@7.28.6': From 2476be94cd764ac5001ed5f6d23178f627214c6e Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Thu, 5 Feb 2026 16:05:12 +0100 Subject: [PATCH 2/8] moved tests to ts --- .../transformers-webworker/jest.config.mjs | 37 +++++---- packages/transformers-webworker/package.json | 1 + .../tests/temp_constants.txt | 3 - ...line.test.js => webWorkerPipeline.test.ts} | 62 ++++++++------- ...st.js => webWorkerPipelineHandler.test.ts} | 32 +++++--- pnpm-lock.yaml | 75 +++++++++++++++++++ 6 files changed, 150 insertions(+), 60 deletions(-) delete mode 100644 packages/transformers-webworker/tests/temp_constants.txt rename packages/transformers-webworker/tests/{webWorkerPipeline.test.js => webWorkerPipeline.test.ts} (72%) rename packages/transformers-webworker/tests/{webWorkerPipelineHandler.test.js => webWorkerPipelineHandler.test.ts} (85%) diff --git a/packages/transformers-webworker/jest.config.mjs b/packages/transformers-webworker/jest.config.mjs index 62e158aaf..8fafa87a8 100644 --- a/packages/transformers-webworker/jest.config.mjs +++ b/packages/transformers-webworker/jest.config.mjs @@ -4,32 +4,31 @@ */ export default { - // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, - - // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, - - // The directory where Jest should output its coverage files coverageDirectory: "coverage", - - // An array of regexp pattern strings used to skip coverage collection coveragePathIgnorePatterns: ["node_modules", "tests"], - - // Indicates which provider should be used to instrument code for coverage coverageProvider: "v8", - - // A list of paths to directories that Jest should use to search for files in roots: ["./tests/"], - - // A map from regular expressions to paths to transformers - transform: {}, - - // Module name mapper to resolve TypeScript files + preset: "ts-jest/presets/default-esm", + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: { + moduleResolution: "node", + esModuleInterop: true, + skipLibCheck: true, + isolatedModules: true, + }, + }, + ], + }, moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", }, - - // Extensions to resolve - moduleFileExtensions: ["js", "ts", "mjs"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "mjs"], + testEnvironment: "node", + extensionsToTreatAsEsm: [".ts"], }; diff --git a/packages/transformers-webworker/package.json b/packages/transformers-webworker/package.json index 8c87190a8..520c9c6b3 100644 --- a/packages/transformers-webworker/package.json +++ b/packages/transformers-webworker/package.json @@ -45,6 +45,7 @@ "esbuild": "^0.27.2", "jest": "^30.2.0", "jest-environment-node": "^30.2.0", + "ts-jest": "^29.2.5", "typescript": "5.9.3" }, "files": [ diff --git a/packages/transformers-webworker/tests/temp_constants.txt b/packages/transformers-webworker/tests/temp_constants.txt deleted file mode 100644 index 31d3e9f7e..000000000 --- a/packages/transformers-webworker/tests/temp_constants.txt +++ /dev/null @@ -1,3 +0,0 @@ -const REQUEST_MESSAGE_TYPE = 'transformersjs_worker_pipeline'; -const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = 'transformersjs_worker_invokeCallback'; -const RESPONSE_MESSAGE_TYPE_RESULT = 'transformersjs_worker_result'; diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.js b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts similarity index 72% rename from packages/transformers-webworker/tests/webWorkerPipeline.test.js rename to packages/transformers-webworker/tests/webWorkerPipeline.test.ts index c5f415c4b..257aa835d 100644 --- a/packages/transformers-webworker/tests/webWorkerPipeline.test.js +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts @@ -1,13 +1,21 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import type { PipelineType } from "@huggingface/transformers"; // Import constants to match the implementation const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; const RESPONSE_MESSAGE_TYPE_RESULT = "transformersjs_worker_result"; +type WebWorkerPipeline = (worker: Worker, task: PipelineType, model_id: string, options?: Record) => Promise<(data: PayloadType, pipeOptions?: Record) => Promise>; + +interface MockWorker { + postMessage: jest.Mock; + onmessage: ((e: MessageEvent) => void) | null; +} + describe("webWorkerPipeline", () => { - let webWorkerPipeline; - let mockWorker; + let webWorkerPipeline: WebWorkerPipeline; + let mockWorker: MockWorker; beforeEach(async () => { // Import the built module @@ -24,12 +32,12 @@ describe("webWorkerPipeline", () => { it("should create a pipeline function", async () => { // Setup the worker to respond setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); }, 0); - const pipeline = await webWorkerPipeline(mockWorker, "sentiment-analysis", "test-model"); + const pipeline = await webWorkerPipeline(mockWorker as any, "sentiment-analysis", "test-model"); expect(typeof pipeline).toBe("function"); expect(mockWorker.postMessage).toHaveBeenCalledWith( @@ -43,17 +51,17 @@ describe("webWorkerPipeline", () => { }); it("should send initialization message with correct parameters", async () => { - const task = "text-classification"; + const task: PipelineType = "text-classification"; const modelId = "my-model"; const options = { device: "cpu" }; setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); }, 0); - await webWorkerPipeline(mockWorker, task, modelId, options); + await webWorkerPipeline(mockWorker as any, task, modelId, options); expect(mockWorker.postMessage).toHaveBeenCalledWith({ id: "init", @@ -73,12 +81,12 @@ describe("webWorkerPipeline", () => { }; setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); }, 0); - await webWorkerPipeline(mockWorker, "test-task", "test-model", options); + await webWorkerPipeline(mockWorker as any, "text-classification", "test-model", options); const callArgs = mockWorker.postMessage.mock.calls[0][0]; expect(callArgs.options.progress_callback).toEqual({ @@ -96,23 +104,23 @@ describe("webWorkerPipeline", () => { setTimeout(() => { // First, send init response - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); // Then simulate callback invocation setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, functionId: "cb_progress_callback", args: [{ status: "progress", progress: 50 }], }, - }); + } as MessageEvent); }, 10); }, 0); - await webWorkerPipeline(mockWorker, "test-task", "test-model", options); + await webWorkerPipeline(mockWorker as any, "text-classification", "test-model", options); // Wait for callback to be invoked await new Promise((resolve) => setTimeout(resolve, 20)); @@ -122,12 +130,12 @@ describe("webWorkerPipeline", () => { it("should send pipeline execution messages", async () => { setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); }, 0); - const pipeline = await webWorkerPipeline(mockWorker, "sentiment-analysis", "test-model"); + const pipeline = await webWorkerPipeline(mockWorker as any, "sentiment-analysis", "test-model"); // Reset mock to clear init call mockWorker.postMessage.mockClear(); @@ -137,13 +145,13 @@ describe("webWorkerPipeline", () => { // Simulate worker response setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: 0, type: RESPONSE_MESSAGE_TYPE_RESULT, result: [{ label: "POSITIVE", score: 0.99 }], }, - }); + } as MessageEvent); }, 10); const result = await executePromise; @@ -164,24 +172,24 @@ describe("webWorkerPipeline", () => { it("should handle errors from worker", async () => { setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: "init", type: RESPONSE_MESSAGE_TYPE_RESULT }, - }); + } as MessageEvent); }, 0); - const pipeline = await webWorkerPipeline(mockWorker, "test-task", "test-model"); + const pipeline = await webWorkerPipeline(mockWorker as any, "text-classification", "test-model"); const executePromise = pipeline("test input"); // Simulate worker error setTimeout(() => { - mockWorker.onmessage({ + mockWorker.onmessage?.({ data: { id: 0, type: RESPONSE_MESSAGE_TYPE_RESULT, error: new Error("Pipeline execution failed"), }, - }); + } as MessageEvent); }, 10); await expect(executePromise).rejects.toThrow("Pipeline execution failed"); diff --git a/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts similarity index 85% rename from packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js rename to packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts index 9327da444..ee73c2650 100644 --- a/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.js +++ b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts @@ -4,10 +4,20 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; +interface WebWorkerPipelineHandler { + (): { + onmessage: (event: MessageEvent) => Promise; + }; +} + +interface MockSelf { + postMessage: jest.Mock; +} + describe("webWorkerPipelineHandler", () => { - let webWorkerPipelineHandler; - let handler; - let mockSelf; + let webWorkerPipelineHandler: WebWorkerPipelineHandler; + let handler: ReturnType; + let mockSelf: MockSelf; beforeEach(async () => { // Clear all mocks @@ -17,7 +27,7 @@ describe("webWorkerPipelineHandler", () => { mockSelf = { postMessage: jest.fn(), }; - global.self = mockSelf; + (global as any).self = mockSelf; // Import the built module const module = await import("../dist/index.js"); @@ -31,17 +41,17 @@ describe("webWorkerPipelineHandler", () => { }); it("should ignore messages without correct type", async () => { - await handler.onmessage({ data: { type: "other" } }); + await handler.onmessage({ data: { type: "other" } } as MessageEvent); expect(mockSelf.postMessage).not.toHaveBeenCalled(); }); it("should ignore messages without data", async () => { - await handler.onmessage({}); + await handler.onmessage({} as MessageEvent); expect(mockSelf.postMessage).not.toHaveBeenCalled(); }); it("should return a promise when handling messages", () => { - const messageEvent = { + const messageEvent: MessageEvent = { data: { id: 1, type: REQUEST_MESSAGE_TYPE, @@ -50,7 +60,7 @@ describe("webWorkerPipelineHandler", () => { options: {}, data: null, }, - }; + } as MessageEvent; // The handler returns a promise (don't await to avoid loading real models) const result = handler.onmessage(messageEvent); @@ -75,7 +85,7 @@ describe("webWorkerPipelineHandler", () => { it("should handle callback invocation structure", () => { // Simulate what would happen when a callback is invoked - const callbackFn = (...args) => + const callbackFn = (...args: any[]) => mockSelf.postMessage({ type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, functionId: "test_callback_id", @@ -94,7 +104,7 @@ describe("webWorkerPipelineHandler", () => { }); it("should handle empty pipeOptions parameter", () => { - const messageWithoutPipeOptions = { + const messageWithoutPipeOptions: MessageEvent = { data: { id: 1, type: REQUEST_MESSAGE_TYPE, @@ -104,7 +114,7 @@ describe("webWorkerPipelineHandler", () => { data: "test input", // pipeOptions omitted }, - }; + } as MessageEvent; // Should not throw synchronously when pipeOptions is undefined expect(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b84269c8..0e9c85c29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: jest-environment-node: specifier: ^30.2.0 version: 30.2.0 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0)(typescript@5.9.3) typescript: specifier: 5.9.3 version: 5.9.3 @@ -976,6 +979,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -1588,6 +1595,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -1604,6 +1614,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -1953,6 +1966,33 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1968,6 +2008,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2964,6 +3008,10 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -3746,6 +3794,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.memoize@4.1.2: {} + lodash@4.17.23: {} long@5.3.2: {} @@ -3760,6 +3810,8 @@ snapshots: dependencies: semver: 7.7.3 + make-error@1.3.6: {} + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -4097,6 +4149,27 @@ snapshots: dependencies: is-number: 7.0.0 + ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0)(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@24.10.9) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.6 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.6) + esbuild: 0.27.2 + jest-util: 30.2.0 + tslib@2.8.1: optional: true @@ -4106,6 +4179,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} typical@7.3.0: {} From 6dd265df874f98f90d56cd5e1a6974e1b391981d Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Thu, 5 Feb 2026 16:08:12 +0100 Subject: [PATCH 3/8] some type improvements --- packages/transformers-webworker/package.json | 1 + .../transformers-webworker/tests/webWorkerPipeline.test.ts | 2 +- pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/transformers-webworker/package.json b/packages/transformers-webworker/package.json index 520c9c6b3..aa57a5172 100644 --- a/packages/transformers-webworker/package.json +++ b/packages/transformers-webworker/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@huggingface/transformers": "workspace:*", + "@jest/globals": "^30.2.0", "@types/jest": "^30.0.0", "esbuild": "^0.27.2", "jest": "^30.2.0", diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts index 257aa835d..4de452a83 100644 --- a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts @@ -88,7 +88,7 @@ describe("webWorkerPipeline", () => { await webWorkerPipeline(mockWorker as any, "text-classification", "test-model", options); - const callArgs = mockWorker.postMessage.mock.calls[0][0]; + const callArgs = mockWorker.postMessage.mock.calls[0][0] as any; expect(callArgs.options.progress_callback).toEqual({ __fn: true, functionId: "cb_progress_callback", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e9c85c29..25f477598 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@huggingface/transformers': specifier: workspace:* version: link:../transformers + '@jest/globals': + specifier: ^30.2.0 + version: 30.2.0 '@types/jest': specifier: ^30.0.0 version: 30.0.0 From 2ed9a903b081c69c10cc04f339e5c7c5ff23a895 Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Thu, 5 Feb 2026 18:42:55 +0100 Subject: [PATCH 4/8] added callback bridge --- packages/transformers-webworker/README.md | 44 +++++- .../transformers-webworker/src/constants.ts | 11 +- packages/transformers-webworker/src/index.ts | 8 - .../callback-bridge/CallbackBridgeClient.ts | 144 ++++++++++++++++++ .../callback-bridge/CallbackBridgeHost.ts | 46 ++++++ .../src/utils/callback-bridge/index.ts | 3 + .../src/utils/callback-bridge/types.ts | 19 +++ .../src/webWorkerPipeline.ts | 41 ++--- .../src/webWorkerPipelineHandler.ts | 33 +--- .../tests/webWorkerPipeline.test.ts | 1 - .../tests/webWorkerPipelineHandler.test.ts | 1 - 11 files changed, 277 insertions(+), 74 deletions(-) create mode 100644 packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts create mode 100644 packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeHost.ts create mode 100644 packages/transformers-webworker/src/utils/callback-bridge/index.ts create mode 100644 packages/transformers-webworker/src/utils/callback-bridge/types.ts diff --git a/packages/transformers-webworker/README.md b/packages/transformers-webworker/README.md index 4a7b86c24..cb51ef954 100644 --- a/packages/transformers-webworker/README.md +++ b/packages/transformers-webworker/README.md @@ -30,11 +30,17 @@ import { webWorkerPipeline } from '@huggingface/transformers-webworker'; // Create your worker const worker = new Worker('./worker.js'); -// Initialize the pipeline +// Initialize the pipeline with options const classifier = await webWorkerPipeline( worker, 'sentiment-analysis', - 'Xenova/distilbert-base-uncased-finetuned-sst-2-english' + 'Xenova/distilbert-base-uncased-finetuned-sst-2-english', + { + // Options like progress_callback are supported via callback bridge + progress_callback: (progress) => { + console.log('Progress:', progress); + } + } ); // Use it like a regular pipeline @@ -56,6 +62,40 @@ self.onmessage = handler.onmessage; > **Note:** The handler internally uses `pipeline` from `@huggingface/transformers` to create and cache pipeline instances. +## Options and Limitations + +### Function Callbacks + +Function callbacks like `progress_callback` are automatically handled via a callback bridge and will execute in the main thread: + +```typescript +const pipe = await webWorkerPipeline(worker, 'text-generation', 'model', { + progress_callback: (progress) => { + console.log('Loading:', progress); + } +}); +``` + +### GPU Acceleration + +Use the `device` parameter to enable GPU acceleration. The worker will handle GPU context creation: + +```typescript +// ✅ Correct: Use device parameter +await webWorkerPipeline(worker, 'text-generation', 'model', { + device: 'webgpu' // or 'webnn' +}); + +// ❌ Incorrect: Don't pass GPU objects in session_options +await webWorkerPipeline(worker, 'text-generation', 'model', { + session_options: { + executionProviders: [{ name: 'webgpu', device: gpuDevice }] // Won't work! + } +}); +``` + +**Note:** `session_options` cannot contain GPU devices, WebNN contexts, or typed arrays as these are not serializable across worker boundaries. + ## Development ```bash diff --git a/packages/transformers-webworker/src/constants.ts b/packages/transformers-webworker/src/constants.ts index 4367e66b7..69459b916 100644 --- a/packages/transformers-webworker/src/constants.ts +++ b/packages/transformers-webworker/src/constants.ts @@ -1,4 +1,7 @@ -export const REQUEST_MESSAGE_TYPE = 'transformersjs_worker_pipeline'; -export const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = 'transformersjs_worker_invokeCallback'; -export const RESPONSE_MESSAGE_TYPE_READY = 'transformersjs_worker_ready'; -export const RESPONSE_MESSAGE_TYPE_RESULT = 'transformersjs_worker_result'; +// Main thread -> Worker +export const REQUEST = 'transformersjs_worker_pipeline'; + +// Worker -> Main thread +export const RESPONSE_READY = 'transformersjs_worker_ready'; +export const RESPONSE_RESULT = 'transformersjs_worker_result'; +export const RESPONSE_CALLBACK_INVOCATION = 'callback_bridge:invoke'; diff --git a/packages/transformers-webworker/src/index.ts b/packages/transformers-webworker/src/index.ts index b0b5e8974..c62711e8a 100644 --- a/packages/transformers-webworker/src/index.ts +++ b/packages/transformers-webworker/src/index.ts @@ -8,11 +8,3 @@ // Web Worker utilities export { default as webWorkerPipeline } from './webWorkerPipeline.js'; export { default as webWorkerPipelineHandler } from './webWorkerPipelineHandler.js'; - -// Constants -export { - REQUEST_MESSAGE_TYPE, - RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, - RESPONSE_MESSAGE_TYPE_READY, - RESPONSE_MESSAGE_TYPE_RESULT, -} from './constants.js'; diff --git a/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts new file mode 100644 index 000000000..f93a5dacf --- /dev/null +++ b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts @@ -0,0 +1,144 @@ +import type { CallbackInvocationMessage, SerializableOptions, SerializedOptions } from './types.js'; +import { RESPONSE_CALLBACK_INVOCATION } from '../../constants'; + +/** + * Main thread side of the callback bridge. + * Serializes functions into callback references and handles invocations from the worker. + */ +export class CallbackBridgeClient { + private callbackMap = new Map(); + private worker: Worker; + private messageHandler: (event: MessageEvent) => void; + + constructor(worker: Worker) { + this.worker = worker; + this.messageHandler = this.handleMessage.bind(this); + this.worker.addEventListener('message', this.messageHandler); + } + + /** + * Serialize options by replacing functions with callback references + * @throws {Error} If options contain non-serializable values like GPU devices or typed arrays + */ + serialize(options: SerializableOptions): SerializedOptions { + const out: SerializedOptions = {}; + + Object.entries(options ?? {}).forEach(([key, value]) => { + if (typeof value === 'function') { + const functionId = `cb_${key}_${Date.now()}_${Math.random().toString(36).slice(2)}`; + this.callbackMap.set(functionId, value); + out[key] = { __fn: true, functionId }; + } else { + // Validate session_options for non-serializable objects + if (key === 'session_options' && value) { + this.validateSessionOptions(value); + } + out[key] = value; + } + }); + + return out; + } + + /** + * Validate session_options for non-serializable objects + * @private + */ + private validateSessionOptions(sessionOptions: any): void { + if (typeof sessionOptions !== 'object' || sessionOptions === null) { + return; + } + + // Check for GPU device objects in executionProviders + if (Array.isArray(sessionOptions.executionProviders)) { + for (const provider of sessionOptions.executionProviders) { + if (typeof provider === 'object' && provider !== null) { + // Check for GPUDevice + if ('device' in provider && provider.device !== null && typeof provider.device === 'object') { + console.warn( + 'CallbackBridgeClient: session_options.executionProviders contains a GPUDevice object which cannot be serialized across worker boundaries. ' + + 'Use the "device" parameter instead (e.g., device: "webgpu") and let the worker create its own GPU context.', + ); + } + // Check for MLContext (WebNN) + if ('context' in provider && provider.context !== null && typeof provider.context === 'object') { + console.warn( + 'CallbackBridgeClient: session_options.executionProviders contains an MLContext object which cannot be serialized across worker boundaries. ' + + 'Use the "device" parameter instead (e.g., device: "webnn") and let the worker create its own WebNN context.', + ); + } + // Check for gpuDevice in WebNN options + if ( + 'gpuDevice' in provider && + provider.gpuDevice !== null && + typeof provider.gpuDevice === 'object' + ) { + console.warn( + 'CallbackBridgeClient: session_options.executionProviders contains a gpuDevice object which cannot be serialized across worker boundaries. ' + + 'Use the "device" parameter instead and let the worker create its own GPU context.', + ); + } + } + } + } + + // Check for external data with typed arrays + if (Array.isArray(sessionOptions.externalData)) { + for (const external of sessionOptions.externalData) { + if ( + external?.data && + (external.data instanceof Uint8Array || + external.data instanceof ArrayBuffer || + ArrayBuffer.isView(external.data)) + ) { + console.warn( + 'CallbackBridgeClient: session_options.externalData contains typed arrays or ArrayBuffers. ' + + 'Models with external data files should be loaded directly in the worker thread, not configured via session_options from the main thread. ' + + 'The worker will automatically handle external data when loading large models.', + ); + break; + } + } + } + } + + /** + * Handle incoming callback invocation messages from the worker + */ + private handleMessage(event: MessageEvent): void { + const msg = event.data as CallbackInvocationMessage; + + if (msg?.type === RESPONSE_CALLBACK_INVOCATION) { + const { functionId, args } = msg; + const fn = this.callbackMap.get(functionId); + + if (fn) { + fn(...args); + } else { + console.warn(`CallbackBridgeClient: Unknown callback function ID: ${functionId}`); + } + } + } + + /** + * Clear a specific callback by its ID + */ + clearCallback(functionId: string): void { + this.callbackMap.delete(functionId); + } + + /** + * Clear all callbacks + */ + clearAllCallbacks(): void { + this.callbackMap.clear(); + } + + /** + * Clean up and remove event listeners + */ + dispose(): void { + this.worker.removeEventListener('message', this.messageHandler); + this.callbackMap.clear(); + } +} diff --git a/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeHost.ts b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeHost.ts new file mode 100644 index 000000000..654108b30 --- /dev/null +++ b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeHost.ts @@ -0,0 +1,46 @@ +import type { SerializedOptions, SerializableOptions, SerializedFunction } from './types.js'; +import { RESPONSE_CALLBACK_INVOCATION } from '../../constants'; + +/** + * Worker thread side of the callback bridge. + * Deserializes callback references into functions that post messages back to main thread. + */ +export class CallbackBridgeHost { + /** + * Deserialize options by replacing callback references with proxy functions + * that post messages back to the main thread + */ + deserialize(options: SerializedOptions): SerializableOptions { + const out: SerializableOptions = {}; + + Object.entries(options ?? {}).forEach(([key, value]) => { + if (this.isSerializedFunction(value)) { + // Create a proxy function that posts a message to invoke the real callback + out[key] = (...args: any[]) => { + self.postMessage({ + type: RESPONSE_CALLBACK_INVOCATION, + functionId: value.functionId, + args, + }); + }; + } else { + out[key] = value; + } + }); + + return out; + } + + /** + * Type guard to check if a value is a serialized function + */ + private isSerializedFunction(value: any): value is SerializedFunction { + return ( + typeof value === 'object' && + value !== null && + '__fn' in value && + value.__fn === true && + 'functionId' in value + ); + } +} diff --git a/packages/transformers-webworker/src/utils/callback-bridge/index.ts b/packages/transformers-webworker/src/utils/callback-bridge/index.ts new file mode 100644 index 000000000..12f2e45b0 --- /dev/null +++ b/packages/transformers-webworker/src/utils/callback-bridge/index.ts @@ -0,0 +1,3 @@ +export { CallbackBridgeClient } from './CallbackBridgeClient.js'; +export { CallbackBridgeHost } from './CallbackBridgeHost.js'; +export type { SerializedFunction, CallbackInvocationMessage, SerializableOptions, SerializedOptions } from './types.js'; diff --git a/packages/transformers-webworker/src/utils/callback-bridge/types.ts b/packages/transformers-webworker/src/utils/callback-bridge/types.ts new file mode 100644 index 000000000..185228fa3 --- /dev/null +++ b/packages/transformers-webworker/src/utils/callback-bridge/types.ts @@ -0,0 +1,19 @@ +/** + * Represents a serialized function reference that can be sent across worker boundaries + */ +export interface SerializedFunction { + __fn: true; + functionId: string; +} + +/** + * Message sent from worker to main thread to invoke a callback + */ +export interface CallbackInvocationMessage { + type: string; + functionId: string; + args: any[]; +} + +export type SerializableOptions = Record; +export type SerializedOptions = Record; diff --git a/packages/transformers-webworker/src/webWorkerPipeline.ts b/packages/transformers-webworker/src/webWorkerPipeline.ts index c01852de9..e8d7eb852 100644 --- a/packages/transformers-webworker/src/webWorkerPipeline.ts +++ b/packages/transformers-webworker/src/webWorkerPipeline.ts @@ -1,9 +1,6 @@ import { PipelineType } from '@huggingface/transformers'; -import { - REQUEST_MESSAGE_TYPE, - RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, - RESPONSE_MESSAGE_TYPE_RESULT, -} from './constants.js'; +import { REQUEST, RESPONSE_RESULT } from './constants.js'; +import { CallbackBridgeClient } from './utils/callback-bridge'; const webWorkerPipeline = ( worker: Worker, @@ -12,35 +9,15 @@ const webWorkerPipeline = ( options: Record = {}, ) => new Promise((resolve, reject) => { - const callbackMap = new Map(); + const callbackBridge = new CallbackBridgeClient(worker); const messagesResolversMap = new Map(); let messageIdCounter = 0; - const serializeOptions = (options: Record) => { - const out: Record = {}; - Object.entries(options ?? {}).forEach(([key, value]) => { - if (typeof value === 'function') { - const functionId = `cb_${key}`; - callbackMap.set(functionId, value); - out[key] = { __fn: true, functionId }; - } else { - out[key] = value; - } - }); - return out; - }; - + const originalOnMessage = worker.onmessage; worker.onmessage = (e) => { const msg = e.data; - if (msg?.type === RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK) { - const { functionId, args } = msg; - const fn = callbackMap.get(functionId); - if (fn) { - fn(...args); - } - } - if (msg?.type === RESPONSE_MESSAGE_TYPE_RESULT) { + if (msg?.type === RESPONSE_RESULT) { if (msg?.id === 'init') { resolve((data: PayloadType, pipeOptions: Record) => { return new Promise((resolve, reject) => { @@ -48,11 +25,11 @@ const webWorkerPipeline = ( messagesResolversMap.set(id, { resolve, reject }); worker.postMessage({ id, - type: REQUEST_MESSAGE_TYPE, + type: REQUEST, data, task, model_id, - options: options ? serializeOptions(options) : {}, + options: options ? callbackBridge.serialize(options) : {}, pipeOptions, }); }); @@ -71,11 +48,11 @@ const webWorkerPipeline = ( messagesResolversMap.set('init', { resolve, reject }); worker.postMessage({ id: 'init', - type: REQUEST_MESSAGE_TYPE, + type: REQUEST, data: null, task: task ?? '', model_id: model_id ?? '', - options: options ? serializeOptions(options) : {}, + options: options ? callbackBridge.serialize(options) : {}, }); }); export default webWorkerPipeline; diff --git a/packages/transformers-webworker/src/webWorkerPipelineHandler.ts b/packages/transformers-webworker/src/webWorkerPipelineHandler.ts index 518f3c233..78ef85c05 100644 --- a/packages/transformers-webworker/src/webWorkerPipelineHandler.ts +++ b/packages/transformers-webworker/src/webWorkerPipelineHandler.ts @@ -1,44 +1,25 @@ import { pipeline } from '@huggingface/transformers'; -import { - REQUEST_MESSAGE_TYPE, - RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, - RESPONSE_MESSAGE_TYPE_READY, - RESPONSE_MESSAGE_TYPE_RESULT, -} from './constants.js'; +import { REQUEST, RESPONSE_READY, RESPONSE_RESULT } from './constants.js'; +import { CallbackBridgeHost } from './utils/callback-bridge'; const pipelines = new Map(); const webWorkerPipelineHandler = () => { - const unserializeOptions = (options: Record) => { - const out: Record = {}; - Object.entries(options ?? {}).forEach(([key, value]) => { - if (typeof value === 'object' && value && '__fn' in value && value.__fn) { - out[key] = (...args: Array) => - self.postMessage({ - type: RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK, - functionId: 'functionId' in value ? value.functionId : null, - args, - }); - } else { - out[key] = value; - } - }); - return out; - }; + const callbackBridge = new CallbackBridgeHost(); return { onmessage: async (event: MessageEvent) => { - if (!event?.data || event.data?.type !== REQUEST_MESSAGE_TYPE) return; + if (!event?.data || event.data?.type !== REQUEST) return; const { id, data, task, model_id, options, pipeOptions = {} } = event.data; const key = JSON.stringify({ task, model_id, options }); let pipe = pipelines.get(key); if (!pipe) { - pipe = await pipeline(task, model_id, unserializeOptions(options)); + pipe = await pipeline(task, model_id, callbackBridge.deserialize(options)); pipelines.set(key, pipe); } - self.postMessage({ id, type: RESPONSE_MESSAGE_TYPE_READY }); + self.postMessage({ id, type: RESPONSE_READY }); const result = data ? await pipe(data, pipeOptions) : null; - self.postMessage({ id, type: RESPONSE_MESSAGE_TYPE_RESULT, result }); + self.postMessage({ id, type: RESPONSE_RESULT, result }); }, }; }; diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts index 4de452a83..1f3822b33 100644 --- a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; import type { PipelineType } from "@huggingface/transformers"; -// Import constants to match the implementation const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; const RESPONSE_MESSAGE_TYPE_RESULT = "transformersjs_worker_result"; diff --git a/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts index ee73c2650..15ea89e52 100644 --- a/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts +++ b/packages/transformers-webworker/tests/webWorkerPipelineHandler.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; -// Import constants to match the implementation const REQUEST_MESSAGE_TYPE = "transformersjs_worker_pipeline"; const RESPONSE_MESSAGE_TYPE_INVOKE_CALLBACK = "transformersjs_worker_invokeCallback"; From f4f3390ebd59835bc54119d045bbe650aa781a17 Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Fri, 13 Feb 2026 08:39:22 +0100 Subject: [PATCH 5/8] Update packages/transformers-webworker/tests/webWorkerPipeline.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../transformers-webworker/tests/webWorkerPipeline.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts index 1f3822b33..5aac5f956 100644 --- a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts @@ -17,8 +17,8 @@ describe("webWorkerPipeline", () => { let mockWorker: MockWorker; beforeEach(async () => { - // Import the built module - const module = await import("../dist/index.js"); + // Import the source module (avoid relying on built dist/) + const module = await import("../src/index"); webWorkerPipeline = module.webWorkerPipeline; // Create a mock Worker From 7b2fe907673c3ac5e000c93e05a421543a6245a0 Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Fri, 13 Feb 2026 08:39:30 +0100 Subject: [PATCH 6/8] Update packages/transformers-webworker/src/webWorkerPipeline.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/transformers-webworker/src/webWorkerPipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transformers-webworker/src/webWorkerPipeline.ts b/packages/transformers-webworker/src/webWorkerPipeline.ts index e8d7eb852..ca437a1ac 100644 --- a/packages/transformers-webworker/src/webWorkerPipeline.ts +++ b/packages/transformers-webworker/src/webWorkerPipeline.ts @@ -19,7 +19,7 @@ const webWorkerPipeline = ( const msg = e.data; if (msg?.type === RESPONSE_RESULT) { if (msg?.id === 'init') { - resolve((data: PayloadType, pipeOptions: Record) => { + resolve((data: PayloadType, pipeOptions: Record = {}) => { return new Promise((resolve, reject) => { const id = messageIdCounter++; messagesResolversMap.set(id, { resolve, reject }); From 6c9ba7c1fd0ec81278ed1a3e9f6ca9a2699b6d8b Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Fri, 13 Feb 2026 08:39:48 +0100 Subject: [PATCH 7/8] Update packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/utils/callback-bridge/CallbackBridgeClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts index f93a5dacf..e57213f4d 100644 --- a/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts +++ b/packages/transformers-webworker/src/utils/callback-bridge/CallbackBridgeClient.ts @@ -17,14 +17,14 @@ export class CallbackBridgeClient { } /** - * Serialize options by replacing functions with callback references - * @throws {Error} If options contain non-serializable values like GPU devices or typed arrays + * Serialize options by replacing functions with callback references. + * Non-serializable values (e.g. certain GPU devices or typed arrays in `session_options`) + * are detected and logged as warnings but do not cause this method to throw. */ serialize(options: SerializableOptions): SerializedOptions { const out: SerializedOptions = {}; Object.entries(options ?? {}).forEach(([key, value]) => { - if (typeof value === 'function') { const functionId = `cb_${key}_${Date.now()}_${Math.random().toString(36).slice(2)}`; this.callbackMap.set(functionId, value); out[key] = { __fn: true, functionId }; From fa607ca38d63d4308d54850fa16c06fd22b1af66 Mon Sep 17 00:00:00 2001 From: Nico Martin Date: Fri, 13 Feb 2026 08:40:06 +0100 Subject: [PATCH 8/8] Update packages/transformers-webworker/tests/webWorkerPipeline.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tests/webWorkerPipeline.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts index 5aac5f956..56106f7b0 100644 --- a/packages/transformers-webworker/tests/webWorkerPipeline.test.ts +++ b/packages/transformers-webworker/tests/webWorkerPipeline.test.ts @@ -88,10 +88,14 @@ describe("webWorkerPipeline", () => { await webWorkerPipeline(mockWorker as any, "text-classification", "test-model", options); const callArgs = mockWorker.postMessage.mock.calls[0][0] as any; - expect(callArgs.options.progress_callback).toEqual({ - __fn: true, - functionId: "cb_progress_callback", - }); + expect(callArgs.options.progress_callback).toEqual( + expect.objectContaining({ + __fn: true, + }), + ); + expect(callArgs.options.progress_callback.functionId).toEqual( + expect.stringMatching(/^cb_progress_callback/), + ); expect(callArgs.options.device).toBe("cpu"); });