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:
| build | worker | size | notes |
|---|---|---|---|
videojs-http-streaming.es.js (the module entry) | live fn + toString(), not minified | ~1 MB | breaks under a consumer's Terser |
videojs-http-streaming.min.js | Blob worker, worker fn already VHS-minified + self-contained | 374 KB | still uses fn.toString(); a second Terser pass is low-risk, not no-risk |
videojs-http-streaming-sync-workers.js | no Worker / Blob / toString() (transmux on the main thread) | ~1.25 MB | needs 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.jsis aliased tovideo.js/core.es.js(Video.js without its own bundled VHS copy) and inlined, instead of staying external. The defaultvideo.jsentry 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-streamingis aliased to thesync-workersbuild 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.jsand@videojs/http-streamingmove 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.jsstill builds its worker withfn.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 likesync-workers, so it would need the samevideo.jsinlining fix anyway, for no upside oversync-workers.sync-workersremoves 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_fnamesetc.). Rejected: the free identifier comes fromcompresshoisting, not justmangle, 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 aspeerDependencies. 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.jswas 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.jsor@videojs/http-streamingdirectly 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.jsupgrades 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 build | External layout | Published 1.0.0 |
|---|---|---|
webpack 5 + Terser 5, or Terser 4.8.1 with the CRA 4 terserOptions; no Babel over dependencies | plays (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.1 | broken: Uncaught ReferenceError: f is not defined in the worker, playback stuck at 0, TS and fMP4 | plays, no worker created |
| the same CRA 4 pipeline with minification turned off | broken: …babel_runtime_helpers_esm_createClass_js__WEBPACK_IMPORTED_MODULE_11__ is not defined | not 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 build | External layout | Published 1.0.0 |
|---|---|---|
| webpack 5, default production minification | 1.06 MB (299 KB gzip) | 0.74 MB (212 KB gzip) |
| CRA 4 pipeline | 1.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.