Skip to content

hushvert/engine

Repository files navigation

hushvert

@hushvert/engine

npm license types

File conversion that never uploads the file.

A JavaScript/TypeScript library that converts images, HEIC, audio, video, archives, PDFs, and data files (CSV/JSON/YAML/XLSX) entirely in the browser, using WebAssembly. The heavy codecs run in Web Workers (images, PDF page ops, DOCX preview, and the worker-backed archive and ffmpeg libraries); the light text and vector modules (SVG, ICO, TXT, data, PDF render) run on the main thread. The bytes never touch a network. Open your network tab during a conversion and watch nothing leave, or pull the plug and watch it still work.

Try it live · Source on GitHub · Quickstart · What it converts · Serving the wasm assets · Heavy formats (hosted API)

This is the open-source engine behind hushvert, the private file converter. Source and issues live at github.com/hushvert/engine; the hosted API for server-only formats is at hushvert.com/for-developers.

Building an AI agent? There is also an MCP server, @hushvert/mcp, that gives your agent (Claude Code, Cursor, Claude Desktop) a convert_file tool over the hosted API. It handles the conversions a browser cannot do, and refuses the ones it can, pointing back to this engine so the agent never pays for a local conversion.

Why

Every other conversion library or API uploads the file to a server. That is a retention policy to trust, a data-processing agreement to sign, and a breach surface, for a file conversion. @hushvert/engine removes the upload entirely:

  • Private by construction. The file stays on the user's device. Not a promise in a privacy policy, a fact you and your users can verify in the network tab.
  • Zero server cost. Conversions run on your users' CPUs. Conversion volume never lands on your compute bill.
  • Compliance-friendly. For apps that handle sensitive files (health, legal, finance, HR), there is no upload to secure in the first place.

Quickstart

npm install @hushvert/engine

Convert your first file in three lines. Images need zero setup, the codecs are bundled:

import { convertFile } from '@hushvert/engine'

// HEIC, PNG, JPG, WebP, AVIF, JXL. No configuration required.
const jpg = await convertFile(
  heicFile,
  { from: 'heic', to: 'jpg', module: 'images' },
  (pct) => console.log(`${pct}%`),
)
// `jpg` is a Blob. It never left the browser.

That is the whole thing. jpg is a Blob you can download or preview, and the input never touched a network. Vite and Rollup run this as-is; on Next.js add one line (transpilePackages: ['@hushvert/engine']), see the bundler note below.

If your bundler complains

@hushvert/engine ships as TypeScript source, on purpose: that keeps its Web Workers, WASM glue, and tree-shaking under your bundler's control, and keeps the no-upload behavior auditable rather than hidden in a pre-built blob. The trade is that your bundler has to transpile the package and resolve its new Worker(new URL('./...worker.ts', import.meta.url)) calls. Most modern bundlers need one line or nothing:

  • Next.js: add it to transpilePackages:
    // next.config.ts
    export default { transpilePackages: ['@hushvert/engine'] }
  • Vite / Rollup: works out of the box. Vite transpiles the published TS and resolves new URL('./x.worker.ts', import.meta.url) workers natively.
  • Webpack 5 (standalone): make sure your ts / babel loader rule does not exclude node_modules/@hushvert/engine. Web Worker URL resolution is on by default in Webpack 5.

If you hit Can't resolve './workers/...worker.ts' or a syntax error on an import from the package, the bundler is not transpiling it - add the line above.

Heavier formats need one config call

Audio, video, archive and PDF-render conversions run from WASM workers your app serves, so they need a one-time configureEngine call pointing at those assets:

import { configureEngine, convertFile } from '@hushvert/engine'

configureEngine({
  ffmpeg: {
    coreUrl: '/vendor/ffmpeg/ffmpeg-core.js',
    wasmUrl: '/vendor/ffmpeg/ffmpeg-core.wasm',
    classWorkerUrl: '/vendor/ffmpeg/worker.js',
  },
  libarchiveWorkerUrl: '/vendor/libarchive/worker-bundle.js',
  pdfjsWorkerUrl: '/vendor/pdfjs/pdf.worker.min.mjs', // only for pdf-render
})

const wav = await convertFile(mp3File, { from: 'mp3', to: 'wav', module: 'audio-video' })

See Serving the wasm assets for those asset paths.

The pair is a plain object: from, to, and which module runs it, plus an optional op: 'merge' for multi-file combines via convertFiles (n PDFs into one, n images into one PDF) and 'compress' for the same-format shrink op. Keep your own format matrix as the routing authority and pass its rows through.

What it converts

Every pair below is reachable today; the modules are the engine's own ENGINE_MODULES, and your app's format matrix stays the routing authority (it decides WHICH pairs exist and passes its rows to convertFile).

Module Runs in Pairs Powered by
images Web Worker png / jpg / webp / avif / jxl interchange, heic / bmp / tiff decode to jpg / png, compress op (jpg re-encode, lossless png oxipng) @jsquash/*, heic-to, utif2
archives main + own worker tar / tar.gz / 7z to zip, plus listing and per-entry extraction libarchive.js, @zip.js/zip.js
pdf Web Worker merge n PDFs into one, combine n images (jpg / png) into one PDF @cantoo/pdf-lib
pdf-render main thread PDF pages out as png / jpg (multi-page becomes a zip) pdfjs-dist
svg main thread svg to png / jpg / pdf (rasterized via <img> + canvas) browser canvas, @cantoo/pdf-lib
ico main thread png / jpg to multi-size ICO favicon browser canvas
txt main thread pdf to txt (text-layer extract), txt to pdf (typeset) pdfjs-dist, @cantoo/pdf-lib
data main thread csv / json / xlsx tabular interchange, json to / from yaml SheetJS (xlsx), js-yaml
audio-video main + own worker mp3 / wav / m4a / flac / ogg audio (and mov / mkv / webm / avi / aac / aiff / wma to mp3), mp4 to webm (small video) ffmpeg.wasm (singlethread)
docx-preview Web Worker docx to clean semantic HTML mammoth

Office documents to PDF, PDF to Word, and large video genuinely cannot run in a browser; those need a server and are out of scope for this client engine. If you need them, hushvert runs a hosted, usage-metered API for exactly those heavy formats (self-serve API key, free monthly allowance, then account credits): see hushvert for developers.

Heavy formats via the hosted API (opt-in)

By default a server-only pair (office docs, PDF to Word, large video) throws unsupported-pair: this engine only runs client work. If you want those heavy formats through the SAME convertFile call, opt in by configuring an API key.

Your hosted API key is a billing secret. Use it only from a trusted server context. Any key placed in browser-bundled code is public: anyone can open DevTools or read your JS bundle and lift it, then spend your credits. So configure hostedApi server-side (a Node route, an edge function, your backend) where the key never reaches the browser. If you need heavy conversions from the browser, put a thin proxy of your own in front of the hosted API and keep the key on the server side of that proxy. The client pairs in this engine never need a key and never touch the network; only this opt-in hosted path does.

// server-side only (e.g. a Node route handler, an edge function, a queue
// worker) - never a file that ends up in the browser bundle.
import { configureEngine, convertFile } from '@hushvert/engine'

configureEngine({
  hostedApi: {
    apiKey: process.env.HUSHVERT_KEY!, // server env var, from hushvert.com/developers/keys
    // baseUrl defaults to 'https://hushvert.com'
  },
})

// Server-only pairs route transparently to the hosted API: submit, upload,
// poll, download. Pass the pair's matrix row; the SDK resolves the slug
// ("<from>-to-<to>" if you do not pass one, the convention for every server
// pair). The `module`/`engine` fields are the host matrix's own values; the SDK
// routes server pairs by `engine: 'server'`, not by `module`.
const pdf = await convertFile(
  docxFile,
  { from: 'docx', to: 'pdf', module: 'office', engine: 'server', slug: 'docx-to-pdf' },
  (pct) => console.log(`${pct}%`),
)

// Client pairs still run 100% in the browser when the SDK runs in the browser,
// exactly as before - configuring hostedApi changes nothing about them.
const png = await convertFile(heicFile, { from: 'heic', to: 'png', module: 'images' })

Cross-origin / CORS

The hosted bridge calls hushvert.com from your origin, so when you run the SDK in a browser (behind your own proxy, or against a self-hosted baseUrl) the hosted endpoints answer cross-origin requests; that is supported. The presigned R2 upload (PUT) and download (GET) are served from the storage host, so that bucket's CORS policy must allow your origin for those two requests to succeed from a browser. From a server context there is no CORS to satisfy.

What stays true, precisely:

  • Client pairs never touch the network. Configuring hostedApi changes nothing about how an image, audio, archive, PDF page-op, or other client pair runs: it is still WASM in your browser, no upload. The CI network-clean test still holds.
  • The hosted path is explicit opt-in, server formats only. It activates only when (a) you configured hostedApi AND (b) the pair is server-only. With no key configured, a server-only pair throws unsupported-pair exactly as before and no request is made. The only request that ever carries your file is the hosted upload, which you opted into for a format a browser cannot do.
  • One file at a time. The hosted API converts a single file per call; pass one file to convertFile.

The hosted call is the only network code in the package, isolated in src/hosted.ts so the no-upload guarantee on the client path stays trivial to audit.

Verify it yourself

The privacy claim is the whole point, so it is built to be falsifiable:

  1. Open your browser DevTools, Network tab.
  2. Convert a file with the engine.
  3. No request carries your file. (Or go fully offline first; conversion still completes, because nothing is being uploaded.)

The hushvert app ships a CI test that fails the build if any request during a conversion carries file bytes or reaches a third-party host.

Serving the wasm assets

Three modules need assets served by your app, same-origin (which is also what keeps the privacy claim verifiable). Copy out of node_modules at build time:

  • libarchive.js/dist/worker-bundle.js and libarchive.js/dist/libarchive.wasm into one directory; pass the bundle URL as libarchiveWorkerUrl.
  • @ffmpeg/core/dist/esm/ffmpeg-core.js + ffmpeg-core.wasm, and @ffmpeg/ffmpeg/dist/esm/worker.js + const.js + errors.js into one directory; pass the three URLs in ffmpeg.
  • pdfjs-dist/build/pdf.worker.min.mjs; pass its URL as pdfjsWorkerUrl (only needed for the pdf-render module).

Everything else (image codecs, zip.js, pdf-lib, mammoth) is bundled by your bundler; the engine creates its workers with the standard new Worker(new URL(...), { type: 'module' }) pattern that webpack, Vite and friends all understand. The package ships TypeScript source and expects to be consumed through a bundler.

Installing behind a private registry (the xlsx dependency)

The spreadsheet (data) module depends on SheetJS, which is declared as a CDN tarball - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/..." - not an npm registry version. This is deliberate: the registry's xlsx is frozen at 0.18.5 with known prototype-pollution / ReDoS CVEs, and 0.20.3 (CDN-only, lockfile-pinned with an integrity hash) is clear of them. The trade-off is that npm install @hushvert/engine needs cdn.sheetjs.com to be reachable.

If you install through a proxy/mirror registry (Artifactory, Nexus, Verdaccio) or behind a firewall, allowlist cdn.sheetjs.com, or pin your own mirror of SheetJS 0.20.3 via an overrides/resolutions entry. If you do not use the data (CSV/JSON/YAML/XLSX) module at all, the import is lazy, so a blocked CDN does not affect any other conversion at runtime - only the install step.

Why singlethread ffmpeg

The multithreaded ffmpeg core needs SharedArrayBuffer, which requires COOP/COEP headers on every embedding page; COEP in turn breaks common third-party embeds (CAPTCHAs, ad iframes) on at least some browsers. The singlethread core runs everywhere with zero header requirements and is fast enough for audio and small video. Hosts who control their headers can swap in the multithreaded core URLs with no engine changes.

Limits worth knowing

  • Audio inputs were tested to 1GB on desktop Chrome; cap near 200MB for low-memory devices.
  • Video encoding is singlethread wasm: budget roughly real time. Keep inputs small (hushvert caps mp4 to webm at 50MB) and route bigger jobs to a server.
  • The docx preview is semantic by design (mammoth): structure survives, page-perfect layout does not.

Contributing

Issues and PRs welcome at github.com/hushvert/engine. The engine is intentionally small and dependency-honest: each module names the library that powers it, and the one rule that never bends is that no module may send file bytes over the network. See CONTRIBUTING.md for the short version and SECURITY.md for responsible disclosure.

Releases (maintainers)

Publishing is automated: bump the version in package.json, then push a matching engine-v* tag (e.g. git tag engine-v0.2.1 && git push origin engine-v0.2.1). CI runs the package gate (prepublishOnly: lint, typecheck, test, build) and publishes to npm with provenance. Without the NPM_TOKEN secret the workflow does a npm publish --dry-run instead, so it is safe to tag before the token exists.

License

The engine's own source is MIT: inspect, run, fork, and ship it. Source: github.com/hushvert/engine.

Some runtime DEPENDENCIES carry their own (including copyleft) licenses, so the combined work you ship is not MIT-only. Two are copyleft:

  • @ffmpeg/core (audio/video, the audio-video module) is GPL-2.0-or-later. Its wasm is served as a separate asset you copy from node_modules (see "Serving the wasm assets"); if you distribute that asset you take on the GPL's source-availability obligations for it.
  • heic-to (HEIC decoding) is LGPL-3.0 and is loaded only when a HEIC conversion runs.

The full per-dependency list is in THIRD_PARTY_LICENSES.md. If your project cannot take on GPL/LGPL obligations, do not enable the audio/video or HEIC paths (the engine is modular and lazy-loads each codec, so unused ones are never pulled in). This disclosure is deliberate: the project's honesty discipline applies to its own license story too.

About

Open source file conversion engine that runs entirely in your browser. Your files never leave your device.

Topics

Resources

License

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors