diff --git a/.ci/bundle-size-budgets.json b/.ci/bundle-size-budgets.json index 07874c3bf..e5138d363 100644 --- a/.ci/bundle-size-budgets.json +++ b/.ci/bundle-size-budgets.json @@ -2,7 +2,7 @@ "web": { "javascript": { "measurement": "bundle", - "softKiB": 35, + "softKiB": 40, "hardKiB": 50 } }, diff --git a/.ci/bundle-size-budgets.md b/.ci/bundle-size-budgets.md index 637dbfcc6..498517987 100644 --- a/.ci/bundle-size-budgets.md +++ b/.ci/bundle-size-budgets.md @@ -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: @@ -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 | @@ -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, diff --git a/.github/scripts/bundle-size-attribution.cjs b/.github/scripts/bundle-size-attribution.cjs new file mode 100644 index 000000000..d1a771d93 --- /dev/null +++ b/.github/scripts/bundle-size-attribution.cjs @@ -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 ", + ); + process.exit(1); + } + for (const row of rows(platform, attribute({ shippedDir, buildDir, repoRoot }))) console.log(row); +} diff --git a/.github/scripts/bundle-size-attribution.test.cjs b/.github/scripts/bundle-size-attribution.test.cjs new file mode 100644 index 000000000..70e99d06b --- /dev/null +++ b/.github/scripts/bundle-size-attribution.test.cjs @@ -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 }, + ); +}); diff --git a/.github/scripts/bundle-size-budgets.cjs b/.github/scripts/bundle-size-budgets.cjs index 0ce9f5496..2a576f408 100644 --- a/.github/scripts/bundle-size-budgets.cjs +++ b/.github/scripts/bundle-size-budgets.cjs @@ -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", @@ -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)) { @@ -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) || @@ -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)) @@ -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"; @@ -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, @@ -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)}` : ""}`; @@ -281,6 +307,7 @@ function conclusion(rows) { module.exports = { platforms, marker, + measurementKey, validateBudgets, measurements, evaluate, diff --git a/.github/scripts/bundle-size-budgets.test.cjs b/.github/scripts/bundle-size-budgets.test.cjs index b6c6966e1..e26f68f7a 100644 --- a/.github/scripts/bundle-size-budgets.test.cjs +++ b/.github/scripts/bundle-size-budgets.test.cjs @@ -183,3 +183,70 @@ test("artifact text cannot inject saved acceptance state into the report", () => assert.deepEqual(policy.readState(body), state); assert.match(body, /Acceptance required/); }); + +test("budgets per-package bundle bytes, with acceptance scoped to the package", () => { + const protocol = "@shopify/checkout-kit-protocol"; + const input = { + budgets: { + web: { + protocol: { measurement: "bundlePackage", package: protocol, softKiB: 16, hardKiB: 20 }, + telemetry: { + measurement: "bundlePackage", + package: "@shopify/checkout-kit-telemetry", + softKiB: 5, + hardKiB: 8, + }, + }, + }, + base: policy.measurements( + `Web\tJavaScript package ${protocol}\t14690\nWeb\tJavaScript package @shopify/checkout-kit-telemetry\t4272\n`, + ), + head: policy.measurements( + `Web\tJavaScript package ${protocol}\t${17 * KiB}\nWeb\tJavaScript package @shopify/checkout-kit-telemetry\t4272\n`, + ), + measuredPlatforms: ["web"], + }; + assert.deepEqual( + policy + .evaluate(input) + .map(({ metric, before, after, status }) => [metric, before, after, status]), + [ + ["protocol", 14690, 17 * KiB, "soft"], + ["telemetry", 4272, 4272, "within"], + ], + ); + const { accepted } = policy.accept( + policy.evaluate(input), + [{ platform: "web", reason: "Adopt the new UCP version" }], + "writer", + comment, + ); + assert.equal(accepted["web.protocol"].package, protocol); + assert.equal(policy.evaluate(input, accepted)[0].status, "accepted"); + // Acceptance for one package does not carry over to another package's budget. + input.budgets.web.protocol.package = "@shopify/checkout-kit"; + input.head["Web\tJavaScript package @shopify/checkout-kit"] = 17 * KiB; + assert.equal(policy.evaluate(input, accepted)[0].status, "soft"); + // A package that no longer contributes bytes is missing, not within budget. + delete input.head["Web\tJavaScript package @shopify/checkout-kit"]; + assert.equal(policy.evaluate(input, accepted)[0].status, "missing"); + const body = policy.render(policy.evaluate(input, accepted), { version: 1 }, ""); + assert.match(body, /Web JavaScript from @shopify\/checkout-kit \(uncompressed\)/); +}); + +test("per-package budgets require a valid package name, and only per-package budgets accept one", () => { + const budget = (fields) => ({ web: { entry: { softKiB: 1, hardKiB: 2, ...fields } } }); + assert.doesNotThrow(() => + policy.validateBudgets(budget({ measurement: "bundlePackage", package: "@scope/name" })), + ); + for (const budgets of [ + budget({ measurement: "bundlePackage" }), + budget({ measurement: "bundlePackage", package: "" }), + budget({ measurement: "bundlePackage", package: "Uppercase" }), + budget({ measurement: "bundlePackage", package: "@scope/name\tfile" }), + budget({ measurement: "bundlePackage", package: "@scope/name", file: "dist/index.js" }), + budget({ measurement: "bundle", package: "@scope/name" }), + { android: { aar: { measurement: "package", package: "lib", softKiB: 1, hardKiB: 2 } } }, + ]) + assert.throws(() => policy.validateBudgets(budgets), JSON.stringify(budgets)); +}); diff --git a/.github/scripts/measure-package-size b/.github/scripts/measure-package-size index 652068e34..24e02742f 100755 --- a/.github/scripts/measure-package-size +++ b/.github/scripts/measure-package-size @@ -118,8 +118,14 @@ measure_web_package() { rm -rf "$js_dir" return 1 fi - printf "Web\tJavaScript\t%s\n" "$javascript_bytes" >> "$output_file" - printf "Web\tJavaScript (gzip)\t%s\n" "$gzip_bytes" >> "$output_file" + { + printf "Web\tJavaScript\t%s\n" "$javascript_bytes" + printf "Web\tJavaScript (gzip)\t%s\n" "$gzip_bytes" + # Split the same shipped JavaScript by source package using its source maps. + # Map sources are relative to the build output, so resolve them from dist/. + node "$script_repo_root/.github/scripts/bundle-size-attribution.cjs" \ + "Web" "$js_dir/package/dist" "$repo_root/platforms/web/dist" "$repo_root" + } >> "$output_file" rm -rf "$js_dir" record_artifact "Web" "npm tarball" "$tarball" record_tarball_breakdown "Web" "npm tarball" "$tarball" @@ -206,6 +212,12 @@ render_comment() { if (artifact == "JavaScript" || artifact == "JavaScript (gzip)") { return "JavaScript bundle"; } + if (index(artifact, "JavaScript package ") == 1) { + return "JavaScript from `" substr(artifact, 20) "`"; + } + if (artifact == "JavaScript unattributed") { + return "JavaScript bundler glue (unattributed)"; + } if (artifact == "npm tarball") { return "npm package (`.tgz`)"; } @@ -216,7 +228,7 @@ render_comment() { } function compression(artifact) { - if (artifact == "JavaScript") return "Uncompressed"; + if (artifact == "JavaScript" || index(artifact, "JavaScript package ") == 1 || artifact == "JavaScript unattributed") return "Uncompressed"; if (artifact == "JavaScript (gzip)" || artifact == "npm tarball") return "gzip"; if (artifact == "release AAR") return "ZIP"; return "Unspecified"; @@ -382,7 +394,7 @@ render_comment() { print ""; print "
How sizes are measured"; print ""; - print "_Measured from the PR base SHA and PR head SHA. Web bundle rows sum shipped `.js`, `.mjs`, and `.cjs` files under `dist/`, excluding source maps and declarations. The gzip bundle size sums files compressed individually with `gzip -n -9`. npm package sizes are gzip-compressed `.tgz` archives; Android AAR sizes are ZIP archives. Package sizes are not final app binary sizes._"; + print "_Measured from the PR base SHA and PR head SHA. Web bundle rows sum shipped `.js`, `.mjs`, and `.cjs` files under `dist/`, excluding source maps and declarations. Per-package JavaScript rows split those same uncompressed bytes by the npm package that owns each source file, using the bundle source maps; unmapped bundler glue is unattributed, and the rows sum to the uncompressed bundle. The gzip bundle size sums files compressed individually with `gzip -n -9`. npm package sizes are gzip-compressed `.tgz` archives; Android AAR sizes are ZIP archives. Package sizes are not final app binary sizes._"; print ""; print "
"; }