Skip to main content

ADR-0006: Inline video.js and @videojs/http-streaming into the package, pinned to core.es.js / sync-workers

  • Status: Accepted
  • Date: 2026-09-10

Context​

@videojs/http-streaming (VHS) was an external of the build, so the shipped dist/index.js carried a bare import "@videojs/http-streaming" and every consumer's bundler resolved and processed VHS itself.

VHS's ESM build (the module entry a bundler picks) constructs its transmuxer and decrypter Web Workers by serialising a live function at runtime:

const workerCode = transform(getWorkerString(function () { /* mux.js inline */ }));
// getWorkerString = fn => fn.toString().replace(/^function.+?{/, '').slice(0, -1)

The worker body is literally fn.toString(). Fine unminified. But when a consumer minifies VHS with Terser (Create React App / webpack, with presets that do mangle plus reduce_vars / collapse_vars / helper hoisting), the string produced by .toString() is left referencing an identifier that only existed in the surrounding module scope. Inside the Blob worker that becomes:

Uncaught ReferenceError: f is not defined
at <blob-uuid>:3:1843

It reproduces only when all three hold:

  • the segments are MPEG-TS: the transmuxer worker (TS → fMP4 for MSE) only runs for TS; fMP4/CMAF segments bypass it and play fine;
  • the build is minified (dev servers are unaffected);
  • the minifier does cross-scope work: Terser does; esbuild's minifier (Vite) is conservative and does not. A webpack build with near-default Terser options is usually spared; an aggressive CRA-style preset reliably triggers it.

:::info Corrected on 2026-09-30

A controlled reproduction (see Update: reproduction below) showed that the minifier is not the cause and that the crash is not limited to MPEG-TS. The consumer's Babel pass over node_modules injects module-scope helper imports into the worker function; Terser only renames the missing helper to f. The decision stands.

:::

VHS ships prebuilt bundles alongside that ESM entry:

buildworkersizenotes
videojs-http-streaming.es.js (the module entry)live fn + toString(), not minified~1 MBbreaks under a consumer's Terser
videojs-http-streaming.min.jsBlob worker, worker fn already VHS-minified + self-contained374 KBstill uses fn.toString(); a second Terser pass is low-risk, not no-risk
videojs-http-streaming-sync-workers.jsno Worker / Blob / toString() (transmux on the main thread)~1.25 MBneeds video.js + @xmldom/xmldom

Decision​

Inline both video.js and VHS into the shipped package: nothing playback- related is left for a consumer's bundler or minifier to touch.

  • video.js is aliased to video.js/core.es.js (Video.js without its own bundled VHS copy) and inlined, instead of staying external. The default video.js entry bundles its own VHS, worker and all, so leaving it external would drag that same vulnerable worker back in regardless of the VHS alias below.
  • @videojs/http-streaming is aliased to the sync-workers build and inlined too, along with @xmldom/xmldom (which that bundle needs for DASH manifest parsing), so the consumer installs nothing.
  • The build is minified (minify: true): safe, since nothing left in the bundle builds a worker from a stringified function.
  • Both video.js and @videojs/http-streaming move to build-time-only dependencies. The package ships with zero runtime dependencies.

This is the second, final cut of the decision, after an interim cut that inlined only VHS turned out not to be enough (see History).

Alternatives considered​

  • Pin to videojs-http-streaming.min.js. Keeps the transmuxer in a Web Worker (off the main thread) and is the artefact VHS recommends for production. Rejected as the default: min.js still builds its worker with fn.toString(), so a second Terser pass over the inlined copy is only unlikely to reintroduce a free identifier, not unable to. It is also UMD-only like sync-workers, so it would need the same video.js inlining fix anyway, for no upside over sync-workers. sync-workers removes the mechanism entirely. Revisit if main-thread transmux jank on live TS is reported.
  • Leave VHS external, document a consumer-side workaround (alias to a prebuilt bundle, or exclude from minification). Rejected: every CRA/webpack consumer hits it and re-implements the same fix.
  • Consumer minifier config only (keep_fnames etc.). Rejected: the free identifier comes from compress hoisting, not just mangle, so it is not a reliable fix, and it is still per-consumer. (The 2026-09-30 reproduction goes further: the build breaks with minification turned off entirely, so no minifier setting can fix it.)
  • Declare video.js / VHS as peerDependencies. Same outcome as leaving them external: the consumer's toolchain resolves, transpiles and minifies VHS. Measured on 2026-09-30 to break under a CRA-style Babel pass and to ship two copies of VHS (see below).

Consequences​

Good

  • The "consumer minifier breaks the VHS worker" bug class is gone: there is no serialised worker function left to mangle, in either an ESM or a CJS build.
  • Consumers install nothing for playback.
  • Zero runtime dependencies.
  • One fewer moving part in every consuming app's bundler config.

Costs / watch-outs

  • TS transmux runs on the main thread, an inherent trade-off of sync-workers. For MPEG-TS HLS this can cause playback-start / seek jank on weak devices. fMP4/CMAF is unaffected. There is no drop-in fix if this bites: videojs-http-streaming.min.js was considered and rejected above for a reason that applies just as much here.
  • The bundle sits around 740 KB. It is a video player library shipping its own engine and streaming stack; acceptable.
  • A consumer that also depends on video.js or @videojs/http-streaming directly would ship a second, separate copy and could double-register the VHS source handler. Consumers should not depend on either directly.
  • Both pinned paths are stable across their current major versions but are not documented public entry points of their packages; check they still exist on a major VHS or Video.js bump.
  • VHS / video.js upgrades now ship only when the library is rebuilt and republished.

History​

VHS was external from the initial build setup. A ReferenceError-in-the-Blob- worker crash was seen in a downstream CRA/webpack app's production build on MPEG-TS HLS streams, which prompted moving VHS in-bundle.

The first cut inlined only VHS, pinned to sync-workers, and left video.js external. Two problems surfaced immediately: the default video.js entry bundles its own copy of VHS (with the same vulnerable worker), so it dragged that worker back in regardless of the VHS alias; and sync-workers is UMD-only, so its require('video.js') to an external package, inside esbuild's ESM output, threw Dynamic require of "video.js" is not supported in any environment without a global require (Vite, native ESM, some webpack setups). Inlining video.js too, aliased to core.es.js, fixed both at once and is now the Decision above.

Update: reproduction (2026-09-30)​

Before the 1.0 release the decision was re-checked with a controlled experiment instead of relying on the original field report.

Setup. The library source was built the pre-decision way (video.js and @videojs/http-streaming left external, no aliases, not minified) and imported by a one-file consumer app that creates an EvePlayer and plays an HLS source. Each app build was loaded in headless Google Chrome (real H.264 support) against a public MPEG-TS stream and a public fMP4 stream; a run passes when playback advances and no worker reports an error. The same app was also built against the published @admmedia/eveplayer@1.0.0.

Consumer buildExternal layoutPublished 1.0.0
webpack 5 + Terser 5, or Terser 4.8.1 with the CRA 4 terserOptions; no Babel over dependenciesplays (TS and fMP4)plays
Create React App 4 production pipeline: webpack 4.44, babel-preset-react-app/dependencies with helpers: true and a production browserslist, Terser 4.8.1broken: Uncaught ReferenceError: f is not defined in the worker, playback stuck at 0, TS and fMP4plays, no worker created
the same CRA 4 pipeline with minification turned offbroken: …babel_runtime_helpers_esm_createClass_js__WEBPACK_IMPORTED_MODULE_11__ is not definednot run

Root cause. Create React App (and any webpack setup that runs Babel with helpers over node_modules) transpiles VHS's worker function for the production browser targets. Transpiling the classes inside that function makes Babel add imports of runtime helpers (_createClass, …) at module scope. The worker is built from fn.toString(), so it calls those helpers without containing them and throws as soon as it evaluates. Terser only renamed the missing helper to f. Development builds were unaffected because CRA's development browserslist targets current browsers only, so there is no class transform and no helpers.

This corrects the Context above on two points: the minifier is not the cause, and the failure is not limited to MPEG-TS. It does not change the decision. It strengthens it: whether an external or peer-dependency VHS works depends on how each consumer compiles node_modules, which a published library cannot control.

Size. The external layout also ships two copies of VHS: the default video.js entry bundles one, and the separate @videojs/http-streaming import adds the other. Measured on the same app:

Consumer buildExternal layoutPublished 1.0.0
webpack 5, default production minification1.06 MB (299 KB gzip)0.74 MB (212 KB gzip)
CRA 4 pipeline1.20 MB (324 KB gzip)0.88 MB (240 KB gzip)

The saving comes from the entry points chosen (core.es.js plus a single VHS), not from inlining as such. It holds as long as the consuming app does not load a second video.js of its own.