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
2 changes: 1 addition & 1 deletion .ci/bundle-size-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"web": {
"javascript": {
"measurement": "bundle",
"softKiB": 35,
"softKiB": 40,
"hardKiB": 50
}
},
Expand Down
46 changes: 41 additions & 5 deletions .ci/bundle-size-budgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,42 @@ shipped JavaScript under `dist`.
Limits are KiB (1,024 bytes), including fractions. Comparisons use exact
bytes, not the rounded numbers displayed in PR reports.

Web starts with a 35 KiB soft limit and 50 KiB hard limit on all shipped JavaScript,
using `"measurement": "bundle"`. This currently measures `dist/index.js`
and will include any additional JavaScript chunks shipped in `dist`. Declarations,
source maps, and other package contents are excluded from this measurement.
Web's deterministic gzip bundle size and npm package size remain informational.
Web has a 40 KiB soft limit and 50 KiB hard limit on all shipped JavaScript,
using `"measurement": "bundle"`. This measures every JavaScript file shipped in
`dist`, including entry files and shared chunks. Declarations, source maps, and
other package contents are excluded from this measurement. Web's deterministic
gzip bundle size and npm package size remain informational.

### Per-package Web JavaScript

The Web bundle also includes code from other packages, such as the protocol and
telemetry clients. `bundle-size-attribution.cjs` splits the same uncompressed
bundle bytes by the npm package that owns each original source file, using the
shipped source maps. Owners come from the nearest `package.json` inside the
repository. JavaScript files without a source map, such as entry shims, belong
to the package that builds them. Unmapped bytes (bundler glue such as
import/export wiring) stay unattributed and count only toward the total. Package
rows plus the unattributed row always sum to the `bundle` measurement.

Budget one package with `"measurement": "bundlePackage"` and its npm name:

```json
{
"web": {
"protocol": {
"measurement": "bundlePackage",
"package": "@shopify/checkout-kit-protocol",
"softKiB": 16,
"hardKiB": 20
}
}
}
```

A budgeted package that no longer contributes bytes reports a missing
measurement. Remove or rename its budget in the same change. Accepting a
per-package breach records that package, so the acceptance cannot satisfy
another package's budget.

Android and React Native use `"measurement": "package"` to budget the complete
compressed artifact:
Expand Down Expand Up @@ -73,6 +104,7 @@ for the updated report. Expired measurement artifacts also require a rerun.
| --- | --- | --- |
| `web` | `bundle` | Sum of raw shipped `.js`, `.mjs`, and `.cjs` files in `dist` |
| `web` | `bundleGzip` | Sum of those files compressed individually with `gzip -n -9` |
| `web` | `bundlePackage` | Raw shipped JavaScript bytes owned by `package` (via source maps) |
| `web` | `package` | Whole compressed npm tarball |
| `react-native` | `package` | Whole compressed wrapper npm tarball |
| `android` | `package` | Whole compressed release AAR |
Expand Down Expand Up @@ -105,6 +137,10 @@ reproducible collector in `measure-package-size`, its changed-path detection and
build setup in `package-size.yml`, and an adapter in `bundle-size-budgets.cjs`.
The evaluator, comment commands, and aggregate check need no platform-specific policy.

**Size budgets** evaluates a PR's budget file with the default branch's policy code.
Land a new measurement type first, then add the budgets that use it in a later
change. Otherwise the check rejects the budget file until the new type merges.

## CI integration

**Package Size** builds the explicit PR head and base SHAs with a read-only token,
Expand Down
122 changes: 122 additions & 0 deletions .github/scripts/bundle-size-attribution.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const fs = require("node:fs");
const { SourceMap } = require("node:module");
const path = require("node:path");

// Attributes every byte of shipped, minified JavaScript to the npm package
// whose source produced it, using the bundle's source maps. Bytes in mapped
// segments belong to the package that owns the original source file (nearest
// package.json). Unmapped bytes (bundler glue such as import/export wiring)
// stay unattributed. A JavaScript file without a source map belongs to the
// package that owns its output location.

const UNATTRIBUTED = "(unattributed)";

// Returns UTF-8 byte counts per original source path (null for unmapped) in one file.
function attributeFile(code, payload) {
const map = new SourceMap(payload);
const counts = new Map();
const add = (source, text) => {
counts.set(source, (counts.get(source) ?? 0) + Buffer.byteLength(text));
};
// Source maps use zero-based lines and UTF-16 columns. Each column belongs to the
// nearest mapping at or before it on the same line. Columns before a line's first
// mapping, and mappings without a source, are unmapped.
const sourceAt = (line, column) => {
const entry = map.findEntry(line, column);
return entry.generatedLine === line ? (entry.originalSource ?? null) : null;
};
const lines = code.split("\n");
lines.forEach((text, line) => {
// Count runs of columns with the same source, so a surrogate pair is never split.
let start = 0;
for (let column = 1; column <= text.length; column++) {
if (column < text.length && sourceAt(line, column) === sourceAt(line, start)) continue;
add(sourceAt(line, start), text.slice(start, column));
start = column;
}
if (line < lines.length - 1) add(null, "\n");
});
return counts;
}

function packageOwner(file, boundary, cache) {
let dir = path.dirname(file);
const stop = path.resolve(boundary);
while (dir === stop || dir.startsWith(stop + path.sep)) {
if (cache.has(dir)) return cache.get(dir);
const manifest = path.join(dir, "package.json");
if (fs.existsSync(manifest)) {
const name = JSON.parse(fs.readFileSync(manifest, "utf8")).name;
const owner = typeof name === "string" && name ? name : UNATTRIBUTED;
cache.set(dir, owner);
return owner;
}
dir = path.dirname(dir);
}
return UNATTRIBUTED;
}

// shippedDir: JavaScript as published (for example an extracted npm tarball's dist/).
// buildDir: where those files were built, so relative map sources resolve to the repository.
// repoRoot: package lookup never leaves this directory.
function attribute({ shippedDir, buildDir, repoRoot }) {
const totals = {};
const cache = new Map();
let bytes = 0;
const add = (owner, count) => {
totals[owner] = (totals[owner] ?? 0) + count;
};
const files = fs
.readdirSync(shippedDir, { recursive: true })
.filter((rel) => /\.(m|c)?js$/.test(rel))
.sort();
for (const rel of files) {
const code = fs.readFileSync(path.join(shippedDir, rel), "utf8");
const size = Buffer.byteLength(code);
bytes += size;
const built = path.join(buildDir, rel);
const mapPath = path.join(shippedDir, `${rel}.map`);
if (!fs.existsSync(mapPath)) {
add(packageOwner(built, repoRoot, cache), size);
continue;
}
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
const sourceRoot = path.resolve(path.dirname(built), map.sourceRoot ?? "");
for (const [source, count] of attributeFile(code, map)) {
const owner =
source === null
? UNATTRIBUTED
: packageOwner(path.resolve(sourceRoot, source.replace(/[?#].*$/, "")), repoRoot, cache);
add(owner, count);
}
}
const attributed = Object.values(totals).reduce((sum, count) => sum + count, 0);
if (attributed !== bytes) throw new Error(`Attributed ${attributed} of ${bytes} bytes`);
return { bytes, totals };
}

// Measurement rows consumed by measure-package-size and bundle-size-budgets.cjs.
function rows(platform, { totals }) {
return Object.entries(totals)
.sort(
([a, x], [b, y]) => (a === UNATTRIBUTED) - (b === UNATTRIBUTED) || y - x || (a < b ? -1 : 1),
)
.map(([owner, count]) =>
owner === UNATTRIBUTED
? `${platform}\tJavaScript unattributed\t${count}`
: `${platform}\tJavaScript package ${owner}\t${count}`,
);
}

module.exports = { UNATTRIBUTED, attributeFile, attribute, rows };

if (require.main === module) {
const [platform, shippedDir, buildDir, repoRoot] = process.argv.slice(2);
if (!platform || !shippedDir || !buildDir || !repoRoot) {
console.error(
"Usage: bundle-size-attribution.cjs <platform> <shipped-dir> <build-dir> <repo-root>",
);
process.exit(1);
}
for (const row of rows(platform, attribute({ shippedDir, buildDir, repoRoot }))) console.log(row);
}
79 changes: 79 additions & 0 deletions .github/scripts/bundle-size-attribution.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const attribution = require("./bundle-size-attribution.cjs");

function write(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}

function fixture() {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-attribution-"));
write(path.join(repo, "web/package.json"), JSON.stringify({ name: "@acme/web" }));
write(path.join(repo, "protocol/package.json"), JSON.stringify({ name: "@acme/protocol" }));
write(path.join(repo, "unnamed/package.json"), "{}");
return repo;
}

test("attributes every shipped byte to its source package", (t) => {
const repo = fixture();
t.after(() => fs.rmSync(repo, { recursive: true, force: true }));
const build = path.join(repo, "web/dist");
const shipped = path.join(repo, "shipped/dist");

// Line 1: "imp;" glue, then protocol code from column 4, then web code from column 11.
// The web code includes a 2-byte character and an emoji (a UTF-16 surrogate pair).
// Line 2: no mappings at all (bundler glue).
// Line 3: unnamed-package code from column 0, then a source-less mapping from column 4.
const code = 'imp;P("x");W("é😀");\nexport{a};\nP();glue';
const map = {
version: 3,
// Relative to web/dist/chunks/, where the map was built. An unnamed package stays unattributed.
sources: [
"../../../protocol/src/codec.ts",
"../../src/index.css?inline",
"../../../unnamed/a.ts",
],
// IAAA = column 4, source 0; OCAA = column +7 (11), source +1; ACAA = column 0, source +1; I = column +4, no source.
mappings: "IAAA,OCAA;;ACAA,I",
};
write(path.join(shipped, "chunks/main.js"), code);
write(path.join(shipped, "chunks/main.js.map"), JSON.stringify(map));
// Entry shims without maps belong to the package that builds them.
write(path.join(shipped, "index.js"), 'export*from"./chunks/main.js";');

const result = attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo });
const shim = Buffer.byteLength('export*from"./chunks/main.js";');
assert.deepEqual(result, {
bytes: Buffer.byteLength(code) + shim,
totals: {
"(unattributed)": 4 + 1 + "export{a};\n".length + 4 + 4,
"@acme/protocol": 'P("x");'.length,
"@acme/web": Buffer.byteLength('W("é😀");') + shim,
},
});
assert.deepEqual(attribution.rows("Web", result), [
`Web\tJavaScript package @acme/web\t${Buffer.byteLength('W("é😀");') + shim}`,
"Web\tJavaScript package @acme/protocol\t7",
`Web\tJavaScript unattributed\t${result.totals["(unattributed)"]}`,
]);
});

test("never attributes sources outside the repository", (t) => {
const repo = fixture();
t.after(() => fs.rmSync(repo, { recursive: true, force: true }));
const build = path.join(repo, "web/dist");
const shipped = path.join(repo, "shipped/dist");
write(path.join(shipped, "a.js"), "abc");
write(
path.join(shipped, "a.js.map"),
JSON.stringify({ version: 3, sources: ["../../../outside.ts"], mappings: "AAAA" }),
);
assert.deepEqual(
attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo }).totals,
{ "(unattributed)": 3 },
);
});
45 changes: 36 additions & 9 deletions .github/scripts/bundle-size-budgets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ const platforms = {
measurements: {
bundle: "JavaScript",
bundleGzip: "JavaScript (gzip)",
// Uncompressed bundle bytes owned by one source package (see bundle-size-attribution.cjs).
bundlePackage: "JavaScript package",
package: "npm tarball",
},
packageMeasurements: ["bundlePackage"],
},
"react-native": {
label: "React Native",
Expand All @@ -30,6 +33,15 @@ function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

const npmName = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;

function measurementKey(platform, budget) {
const { label, measurements: artifacts } = platforms[platform];
const artifact = artifacts[budget.measurement];
if (budget.package) return `${label}\t${artifact} ${budget.package}`;
return `${label}\t${artifact}${budget.file ? `\t${budget.file}` : ""}`;
}

function validateBudgets(budgets) {
if (!object(budgets)) throw new Error("Budgets must be an object");
for (const [platform, metrics] of Object.entries(budgets)) {
Expand All @@ -53,9 +65,19 @@ function validateBudgets(budgets) {
budget.file.split("/").some((part) => !part || part === "." || part === ".."))
)
throw new Error(`File must be an exact package-relative path for ${platform}.${metric}`);
const byPackage = (platforms[platform].packageMeasurements ?? []).includes(
budget.measurement,
);
if (
byPackage !== Object.hasOwn(budget, "package") ||
(byPackage && (typeof budget.package !== "string" || !npmName.test(budget.package)))
)
throw new Error(
`Package must name the npm package measured by ${platform}.${metric}, and only for per-package measurements`,
);
if (
Object.keys(budget).some(
(key) => !["measurement", "file", "softKiB", "hardKiB"].includes(key),
(key) => !["measurement", "file", "package", "softKiB", "hardKiB"].includes(key),
) ||
!Number.isFinite(budget.softKiB) ||
!Number.isFinite(budget.hardKiB) ||
Expand Down Expand Up @@ -103,9 +125,8 @@ function evaluate({ budgets, base, head, measuredPlatforms }, acceptances = {})
if (!measuredPlatforms.includes(platform)) continue;
for (const [metric, budget] of Object.entries(metrics)) {
const key = `${platform}.${metric}`;
const measurementKey = `${platforms[platform].label}\t${platforms[platform].measurements[budget.measurement]}${budget.file ? `\t${budget.file}` : ""}`;
const before = base[measurementKey];
const after = head[measurementKey];
const before = base[measurementKey(platform, budget)];
const after = head[measurementKey(platform, budget)];
const acceptance = acceptances[key];
let status;
if (!Number.isSafeInteger(after) || after < 0 || (after === 0 && !budget.file))
Expand All @@ -117,6 +138,7 @@ function evaluate({ budgets, base, head, measuredPlatforms }, acceptances = {})
acceptance &&
acceptance.measurement === budget.measurement &&
acceptance.file === budget.file &&
acceptance.package === budget.package &&
after <= acceptance.bytes
)
status = "accepted";
Expand Down Expand Up @@ -150,6 +172,7 @@ function accept(rows, commands, actor, comment, previous = {}) {
bytes: row.after,
measurement: row.measurement,
...(row.file ? { file: row.file } : {}),
...(row.package ? { package: row.package } : {}),
actor,
reason,
commentId: comment.id,
Expand Down Expand Up @@ -227,16 +250,19 @@ function render(rows, state, packageComment, notes = []) {
: `${kib(row.after)} (${delta === "unavailable" ? "change unavailable" : delta})`;
const scope = row.file
? `${escape(row.file)} (uncompressed)`
: {
bundle: "JavaScript (uncompressed)",
bundleGzip: "JavaScript (gzip)",
package: platforms[row.platform].packageLabel,
}[row.measurement];
: row.package
? `JavaScript from ${escape(row.package)} (uncompressed)`
: {
bundle: "JavaScript (uncompressed)",
bundleGzip: "JavaScript (gzip)",
package: platforms[row.platform].packageLabel,
}[row.measurement];
const named = rows.some(
(other) =>
other.platform === row.platform &&
other.measurement === row.measurement &&
other.file === row.file &&
other.package === row.package &&
other.metric !== row.metric,
);
const budget = `${platforms[row.platform].label} ${scope}${named ? ` / ${escape(row.metric)}` : ""}`;
Expand Down Expand Up @@ -281,6 +307,7 @@ function conclusion(rows) {
module.exports = {
platforms,
marker,
measurementKey,
validateBudgets,
measurements,
evaluate,
Expand Down
Loading
Loading