Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .escheckrc
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
"module": true,
"checkFeatures": true,
"ignore": "globalThis,ErrorCause",
"files": ["./dist/hls.mjs", "./dist/hls.light.mjs"]
"files": [
"./dist/hls.mjs",
"./dist/hls.min.mjs",
"./dist/hls.light.mjs",
"./dist/hls.light.min.mjs"
]
}
]
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ jobs:
env:
CI: true

- name: check bundle size
run: |
npm run size:check
env:
CI: true

- name: upload build
uses: actions/upload-artifact@v4
with:
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,19 @@ Build and watch (customized dev setups where you'll want to host through another
npm run build:watch
```

Only specific flavor (known configs are: debug, dist, light, light-dist, demo):
Only specific flavors (known configs are: `full`, `fullMin`, `fullEsm`, `fullEsmMin`, `light`, `lightMin`, `lightEsm`, `lightEsmMin`, `worker`, `demo`):

```
npm run build -- --env dist # replace "dist" by other configuration name, see above ^
npm run build -- --configType fullMin # repeat --configType to build more than one
```

Note: The "demo" config is always built.
Report the size of the built `dist/` files, and check them against the budgets in
`dist-size-budget.json` (the same check CI runs):

```
npm run size
npm run size:check
```

**NOTE:** `hls.light.*.js` dist files do not include alternate-audio, subtitles, CMCD, EME (DRM), Variable Substitution, Interstitials, I-frame trick-play, Media Capabilities, or MPEG-2 TS advanced codec (HEVC and AC-3) support. Content Steering is included. In addition, the following types are not available in the light build:

Expand Down Expand Up @@ -329,7 +335,15 @@ Optional features such as CMCD pull in ES2017 APIs (e.g. `Object.entries`), so t
The `dist/` folder ships two distribution variants:

- **UMD** (`dist/hls.js`, `dist/hls.min.js`, `dist/hls.light.js`, `dist/hls.light.min.js`) — embeddable directly via a `<script>` tag (exposes a global `Hls`) or resolved by `require('hls.js')` via `package.json`'s `main` field. Targets the browser list above. The companion `dist/hls.worker.js` is the bundled transmuxer Web Worker.
- **ESM** (`dist/hls.mjs`, `dist/hls.light.mjs`) — resolved by `import 'hls.js'` via the `module` field. Built with `@babel/preset-env`'s `esmodules: true` target (≈ Chrome 61+, Firefox 60+, Safari 10.1+, Edge 16+) and intended to be consumed by a modern bundler. Uses ES2015+ syntax but stays below ES2019 (no `Array.prototype.flatMap`, `Object.fromEntries`, etc.).
- **ESM** (`dist/hls.mjs`, `dist/hls.light.mjs`, plus the minified `dist/hls.min.mjs` and `dist/hls.light.min.mjs`) — `import 'hls.js'` resolves to the unminified `dist/hls.mjs` via the `module` field, which is what you want when a bundler will minify it for you. The `.min.mjs` files exist for loading straight from a CDN with `<script type="module">`. Built with `@babel/preset-env`'s `esmodules: true` target (≈ Chrome 61+, Firefox 60+, Safari 10.1+, Edge 16+) and intended to be consumed by a modern bundler. Uses ES2015+ syntax but stays below ES2019 (no `Array.prototype.flatMap`, `Object.fromEntries`, etc.).

> **The ESM builds do not bundle the transmuxer Web Worker.** The UMD builds inline it, but `dist/hls.mjs` and `dist/hls.min.mjs` do not, so transmuxing runs on the main thread unless you point `workerPath` at the separately published worker:
>
> ```js
> const hls = new Hls({
> workerPath: 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.worker.js',
> });
> ```

If you import from `src/` directly or include any of our runtime dependencies untranspiled in your own build, you bypass this Babel pipeline and become responsible for transpilation; those source modules can reach for ES2019+ APIs that are tree-shaken out of the bundles we publish.

Expand Down
12 changes: 12 additions & 0 deletions build-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,12 @@ const configs = Object.entries({
format: FORMAT.esm,
minified: false,
}),
fullEsmMin: buildRollupConfig({
input: './src/exports-named.ts',
type: BUILD_TYPE.full,
format: FORMAT.esm,
minified: true,
}),
light: buildRollupConfig({
type: BUILD_TYPE.light,
format: FORMAT.umd,
Expand All @@ -420,6 +426,12 @@ const configs = Object.entries({
format: FORMAT.esm,
minified: false,
}),
lightEsmMin: buildRollupConfig({
input: './src/exports-named.ts',
type: BUILD_TYPE.light,
format: FORMAT.esm,
minified: true,
}),
worker: {
input: './src/demux/transmuxer-worker.ts',
onLog: buildOnLog(),
Expand Down
19 changes: 19 additions & 0 deletions dist-size-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$comment": [
"Brotli byte budgets for dist/, enforced by `npm run size:check`.",
"Raise a number here in the PR that grows the bundle. `npm run size` prints",
"current sizes. `tolerance` allows a 2% margin over each budget."
],
"tolerance": 0.02,
"files": {
"hls.js": 283783,
"hls.min.js": 153423,
"hls.mjs": 267422,
"hls.min.mjs": 147479,
"hls.light.js": 187388,
"hls.light.min.js": 99919,
"hls.light.mjs": 177621,
"hls.light.min.mjs": 97137,
"hls.worker.js": 35918
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"build:copy-types": "cp ./dist/hls.d.ts ./dist/hls.d.mts && cp ./dist/hls.d.ts ./dist/hls.js.d.ts",
"dev": "run-p build:watch serve",
"serve": "http-server -o /demo .",
"size": "node ./scripts/dist-size.js",
"size:check": "node ./scripts/dist-size.js --check",
"docs": "doctoc ./docs/API.md && api-documenter markdown -i api-extractor -o api-extractor/api-documenter && rm api-extractor/api-documenter/index.md && npm run docs-md-to-html",
"docs-md-to-html": "generate-md --layout github --input api-extractor/api-documenter --output api-docs",
"lint": "eslint --cache src/ tests/ --ext .js --ext .ts",
Expand Down
201 changes: 201 additions & 0 deletions scripts/dist-size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/* eslint-env node */
'use strict';

/**
* Reports dist/ file sizes and, with --check, exits non-zero when any file
* exceeds its limit from dist-size-budget.json.
*
* Limits are compared against brotli size, which is what CDNs serve.
*/

const fs = require('fs');
const path = require('path');
const zlib = require('zlib');

const ROOT = path.resolve(__dirname, '..');
const BUDGET_FILE = path.join(ROOT, 'dist-size-budget.json');

/**
* Resolves each budget to the byte limit enforced against it.
*
* Values are validated rather than defaulted: a tolerance that failed to parse
* would raise every limit and let regressions through unnoticed.
*/
function loadLimits() {
let config;
try {
config = JSON.parse(fs.readFileSync(BUDGET_FILE, { encoding: 'utf-8' }));
} catch (error) {
throw new Error(`Cannot read ${BUDGET_FILE}: ${error.message}`);
}

const { tolerance = 0, files } = config;

if (typeof tolerance !== 'number' || !isFinite(tolerance) || tolerance < 0) {
throw new Error(
`${BUDGET_FILE}: "tolerance" must be a non-negative number, got ${JSON.stringify(tolerance)}.`,
);
}
if (!files || typeof files !== 'object') {
throw new Error(`${BUDGET_FILE}: "files" must be an object of budgets.`);
}

return Object.keys(files).map((name) => {
const budget = files[name];
if (typeof budget !== 'number' || !isFinite(budget) || budget <= 0) {
throw new Error(
`${BUDGET_FILE}: budget for "${name}" must be a positive number, got ${JSON.stringify(budget)}.`,
);
}
return { name, budget, limit: Math.round(budget * (1 + tolerance)) };
});
}

function measure(file) {
const raw = fs.readFileSync(file);
return {
raw: raw.length,
gzip: zlib.gzipSync(raw, { level: zlib.constants.Z_BEST_COMPRESSION })
.length,
brotli: zlib.brotliCompressSync(raw, {
params: {
// Pinned so the reported number does not drift with the Node version.
[zlib.constants.BROTLI_PARAM_QUALITY]:
zlib.constants.BROTLI_MAX_QUALITY,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.length,
},
}).length,
};
}

function formatBytes(bytes) {
const sign = bytes < 0 ? '-' : '';
return `${sign}${(Math.abs(bytes) / 1024).toFixed(1)} KiB`;
}

function measureAll(limits) {
const measured = [];
const missing = [];

limits.forEach(({ name, limit }) => {
const file = path.join(ROOT, 'dist', name);
if (!fs.existsSync(file)) {
missing.push(name);
return;
}
const sizes = measure(file);
measured.push({
name,
...sizes,
limit,
headroom: limit - sizes.brotli,
});
});

return { measured, missing };
}

function printTable(rows) {
const columns = [
{ title: 'file', of: (row) => row.name, align: 'left' },
{ title: 'raw', of: (row) => formatBytes(row.raw) },
{ title: 'gzip', of: (row) => formatBytes(row.gzip) },
{ title: 'brotli', of: (row) => formatBytes(row.brotli) },
{ title: 'limit', of: (row) => formatBytes(row.limit) },
{ title: 'headroom', of: (row) => formatBytes(row.headroom) },
];

const widths = columns.map((column) =>
Math.max(column.title.length, ...rows.map((row) => column.of(row).length)),
);
const line = (cells) =>
cells
.map((cell, i) =>
columns[i].align === 'left'
? cell.padEnd(widths[i])
: cell.padStart(widths[i]),
)
.join(' ');

console.log(line(columns.map((column) => column.title)));
console.log('-'.repeat(widths.reduce((sum, w) => sum + w + 2, -2)));
rows.forEach((row) => {
console.log(
`${line(columns.map((column) => column.of(row)))}${row.headroom < 0 ? ' OVER LIMIT' : ''}`,
);
});
}

function writeStepSummary(rows, file) {
const cells = (row) => [
row.headroom < 0 ? `⚠️ \`${row.name}\`` : `\`${row.name}\``,
formatBytes(row.raw),
formatBytes(row.gzip),
formatBytes(row.brotli),
formatBytes(row.limit),
formatBytes(row.headroom),
];

fs.appendFileSync(
file,
[
'## Bundle size',
'',
'| file | raw | gzip | brotli | limit | headroom |',
'| --- | ---: | ---: | ---: | ---: | ---: |',
...rows.map((row) => `| ${cells(row).join(' | ')} |`),
'',
`Compared after brotli compression against the limits in \`dist-size-budget.json\`.`,
'',
].join('\n'),
);
}

let limits;
try {
limits = loadLimits();
} catch (error) {
console.error(error.message);
process.exit(1);
}

const { measured, missing } = measureAll(limits);

if (measured.length) {
printTable(measured);
}
if (missing.length) {
console.log(`\nNot built: ${missing.join(', ')}`);
}
if (process.env.GITHUB_STEP_SUMMARY && measured.length) {
writeStepSummary(measured, process.env.GITHUB_STEP_SUMMARY);
}

if (!process.argv.includes('--check')) {
process.exit(0);
}

if (missing.length) {
console.error(
`\nCannot check bundle size: ${missing.length} file(s) missing from dist/. Run \`npm run build\` first.`,
);
process.exit(1);
}

const over = measured.filter((row) => row.headroom < 0);
if (over.length) {
console.error('\nBundle size limit exceeded:');
over.forEach((row) => {
console.error(
` ${row.name}: ${formatBytes(row.brotli)} brotli is ${formatBytes(-row.headroom)} over the ${formatBytes(row.limit)} limit.`,
);
});
console.error(
'\nIf the growth is intended, raise the budget in dist-size-budget.json in this change so the increase is reviewed.',
);
process.exit(1);
}

console.log('\nAll files within budget.');
Loading