From 41b39a72d6c50019db6befea525eb8be0785d3f2 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Thu, 8 Oct 2026 14:22:32 +0100 Subject: [PATCH 1/2] Attribute Web bundle size to source packages Split the shipped Web JavaScript by the npm package that owns each source file, using the bundle's source maps. The size report shows per-package base/head/delta rows that sum exactly to the uncompressed bundle, with unmapped bundler glue reported separately. Add a `bundlePackage` budget measurement so a later change can budget each package. Size budgets evaluates budget files with the default branch's policy, so the budgets themselves follow in a separate change. Raise the total Web JavaScript soft limit from 35 KiB to 40 KiB. main is 6 bytes under 35 KiB, so nearly every Web PR would otherwise need /accept-size; per-package budgets take over attributing growth. --- .ci/bundle-size-budgets.json | 2 +- .ci/bundle-size-budgets.md | 46 +++++- .github/scripts/bundle-size-attribution.cjs | 150 ++++++++++++++++++ .../scripts/bundle-size-attribution.test.cjs | 142 +++++++++++++++++ .github/scripts/bundle-size-budgets.cjs | 45 ++++-- .github/scripts/bundle-size-budgets.test.cjs | 67 ++++++++ .github/scripts/measure-package-size | 20 ++- 7 files changed, 453 insertions(+), 19 deletions(-) create mode 100644 .github/scripts/bundle-size-attribution.cjs create mode 100644 .github/scripts/bundle-size-attribution.test.cjs 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..43fd7f964 --- /dev/null +++ b/.github/scripts/bundle-size-attribution.cjs @@ -0,0 +1,150 @@ +const fs = require("node:fs"); +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)"; +const BASE64 = Object.fromEntries( + [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map((c, i) => [c, i]), +); + +function decodeSegment(segment) { + const values = []; + let value = 0; + let shift = 0; + for (const char of segment) { + const digit = BASE64[char]; + if (digit === undefined) throw new Error(`Invalid source map mapping: ${segment}`); + value += (digit & 31) << shift; + if (digit & 32) { + shift += 5; + continue; + } + values.push(value & 1 ? -(value >>> 1) : value >>> 1); + value = 0; + shift = 0; + } + if (shift !== 0) throw new Error(`Truncated source map mapping: ${segment}`); + return values; +} + +// Returns byte counts per source index (null for unmapped) for one file. +function attributeFile(code, map) { + const counts = new Map(); + const add = (source, bytes) => { + if (bytes > 0) counts.set(source, (counts.get(source) ?? 0) + bytes); + }; + const lines = code.split("\n"); + const mappingLines = map.mappings.split(";"); + let source = 0; + lines.forEach((line, index) => { + const newline = index < lines.length - 1 ? 1 : 0; + const segments = []; + let column = 0; + for (const raw of (mappingLines[index] ?? "").split(",")) { + if (!raw) continue; + const fields = decodeSegment(raw); + column += fields[0]; + if (fields.length > 1) source += fields[1]; + segments.push([column, fields.length > 1 ? source : null]); + } + // Source map columns are UTF-16 offsets, so slice the string, then count UTF-8 bytes. + const start = segments.length ? segments[0][0] : line.length; + add(null, Buffer.byteLength(line.slice(0, start)) + newline); + segments.forEach(([from, owner], i) => { + const to = i + 1 < segments.length ? segments[i + 1][0] : line.length; + add(owner, Buffer.byteLength(line.slice(from, to))); + }); + }); + 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 [index, count] of attributeFile(code, map)) { + const source = index === null ? undefined : map.sources[index]; + if (index !== null && typeof source !== "string") + throw new Error(`Source map for ${rel} references missing source ${index}`); + const owner = + source === undefined + ? 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, decodeSegment, 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..e693d5b0b --- /dev/null +++ b/.github/scripts/bundle-size-attribution.test.cjs @@ -0,0 +1,142 @@ +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"); + +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +function encode(values) { + return values + .map((n) => { + let value = n < 0 ? (-n << 1) | 1 : n << 1; + let out = ""; + do { + let digit = value & 31; + value >>>= 5; + if (value) digit |= 32; + out += BASE64[digit]; + } while (value); + return out; + }) + .join(""); +} + +// Lines of [column, sourceIndex | null] segments, with absolute values, encoded as deltas. +function mappings(lines) { + let source = 0; + return lines + .map((segments) => { + let column = 0; + return segments + .map(([col, src]) => { + const fields = [col - column]; + column = col; + if (src !== null) { + fields.push(src - source, 0, 0); + source = src; + } + return encode(fields); + }) + .join(","); + }) + .join(";"); +} + +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("decodes VLQ segments, including negative and multi-digit values", () => { + assert.deepEqual(attribution.decodeSegment(encode([0, -3, 16, 1000])), [0, -3, 16, 1000]); + assert.throws(() => attribution.decodeSegment("g"), /Truncated/); + assert.throws(() => attribution.decodeSegment("!"), /Invalid/); +}); + +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: "import" glue, then protocol code, then web code with a multibyte character. + // Line 2: no mappings at all (bundler glue). Line 3: a one-field (unmapped) segment. + 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", + ], + mappings: mappings([ + [ + [4, 0], + [11, 1], + ], + [], + [ + [0, 2], + [4, null], + ], + ]), + }; + 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 and rejects broken maps", (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: mappings([[[0, 0]]]), + }), + ); + assert.deepEqual( + attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo }).totals, + { "(unattributed)": 3 }, + ); + write( + path.join(shipped, "a.js.map"), + JSON.stringify({ version: 3, sources: [], mappings: mappings([[[0, 0]]]) }), + ); + assert.throws( + () => attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo }), + /missing source/, + ); +}); 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 "
"; } From b1bbd5f6a76f7128f453e1c85d7100c31055348f Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Thu, 8 Oct 2026 14:38:00 +0100 Subject: [PATCH 2/2] Use Node's source map parser for bundle attribution Replace the hand-written VLQ decoder with node:module's SourceMap. Count runs of columns with the same source so surrogate pairs are never split. Attribution is byte-identical on the npm and CDN bundles. --- .github/scripts/bundle-size-attribution.cjs | 76 ++++++----------- .../scripts/bundle-size-attribution.test.cjs | 85 +++---------------- 2 files changed, 35 insertions(+), 126 deletions(-) diff --git a/.github/scripts/bundle-size-attribution.cjs b/.github/scripts/bundle-size-attribution.cjs index 43fd7f964..d1a771d93 100644 --- a/.github/scripts/bundle-size-attribution.cjs +++ b/.github/scripts/bundle-size-attribution.cjs @@ -1,4 +1,5 @@ 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 @@ -9,57 +10,31 @@ const path = require("node:path"); // package that owns its output location. const UNATTRIBUTED = "(unattributed)"; -const BASE64 = Object.fromEntries( - [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map((c, i) => [c, i]), -); -function decodeSegment(segment) { - const values = []; - let value = 0; - let shift = 0; - for (const char of segment) { - const digit = BASE64[char]; - if (digit === undefined) throw new Error(`Invalid source map mapping: ${segment}`); - value += (digit & 31) << shift; - if (digit & 32) { - shift += 5; - continue; - } - values.push(value & 1 ? -(value >>> 1) : value >>> 1); - value = 0; - shift = 0; - } - if (shift !== 0) throw new Error(`Truncated source map mapping: ${segment}`); - return values; -} - -// Returns byte counts per source index (null for unmapped) for one file. -function attributeFile(code, map) { +// 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, bytes) => { - if (bytes > 0) counts.set(source, (counts.get(source) ?? 0) + bytes); + 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"); - const mappingLines = map.mappings.split(";"); - let source = 0; - lines.forEach((line, index) => { - const newline = index < lines.length - 1 ? 1 : 0; - const segments = []; - let column = 0; - for (const raw of (mappingLines[index] ?? "").split(",")) { - if (!raw) continue; - const fields = decodeSegment(raw); - column += fields[0]; - if (fields.length > 1) source += fields[1]; - segments.push([column, fields.length > 1 ? source : null]); + 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; } - // Source map columns are UTF-16 offsets, so slice the string, then count UTF-8 bytes. - const start = segments.length ? segments[0][0] : line.length; - add(null, Buffer.byteLength(line.slice(0, start)) + newline); - segments.forEach(([from, owner], i) => { - const to = i + 1 < segments.length ? segments[i + 1][0] : line.length; - add(owner, Buffer.byteLength(line.slice(from, to))); - }); + if (line < lines.length - 1) add(null, "\n"); }); return counts; } @@ -107,12 +82,9 @@ function attribute({ shippedDir, buildDir, repoRoot }) { } const map = JSON.parse(fs.readFileSync(mapPath, "utf8")); const sourceRoot = path.resolve(path.dirname(built), map.sourceRoot ?? ""); - for (const [index, count] of attributeFile(code, map)) { - const source = index === null ? undefined : map.sources[index]; - if (index !== null && typeof source !== "string") - throw new Error(`Source map for ${rel} references missing source ${index}`); + for (const [source, count] of attributeFile(code, map)) { const owner = - source === undefined + source === null ? UNATTRIBUTED : packageOwner(path.resolve(sourceRoot, source.replace(/[?#].*$/, "")), repoRoot, cache); add(owner, count); @@ -136,7 +108,7 @@ function rows(platform, { totals }) { ); } -module.exports = { UNATTRIBUTED, decodeSegment, attributeFile, attribute, rows }; +module.exports = { UNATTRIBUTED, attributeFile, attribute, rows }; if (require.main === module) { const [platform, shippedDir, buildDir, repoRoot] = process.argv.slice(2); diff --git a/.github/scripts/bundle-size-attribution.test.cjs b/.github/scripts/bundle-size-attribution.test.cjs index e693d5b0b..70e99d06b 100644 --- a/.github/scripts/bundle-size-attribution.test.cjs +++ b/.github/scripts/bundle-size-attribution.test.cjs @@ -5,44 +5,6 @@ const os = require("node:os"); const path = require("node:path"); const attribution = require("./bundle-size-attribution.cjs"); -const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -function encode(values) { - return values - .map((n) => { - let value = n < 0 ? (-n << 1) | 1 : n << 1; - let out = ""; - do { - let digit = value & 31; - value >>>= 5; - if (value) digit |= 32; - out += BASE64[digit]; - } while (value); - return out; - }) - .join(""); -} - -// Lines of [column, sourceIndex | null] segments, with absolute values, encoded as deltas. -function mappings(lines) { - let source = 0; - return lines - .map((segments) => { - let column = 0; - return segments - .map(([col, src]) => { - const fields = [col - column]; - column = col; - if (src !== null) { - fields.push(src - source, 0, 0); - source = src; - } - return encode(fields); - }) - .join(","); - }) - .join(";"); -} - function write(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); @@ -56,21 +18,17 @@ function fixture() { return repo; } -test("decodes VLQ segments, including negative and multi-digit values", () => { - assert.deepEqual(attribution.decodeSegment(encode([0, -3, 16, 1000])), [0, -3, 16, 1000]); - assert.throws(() => attribution.decodeSegment("g"), /Truncated/); - assert.throws(() => attribution.decodeSegment("!"), /Invalid/); -}); - 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: "import" glue, then protocol code, then web code with a multibyte character. - // Line 2: no mappings at all (bundler glue). Line 3: a one-field (unmapped) segment. - const code = 'imp;P("x");W("é");\nexport{a};\nP();glue'; + // 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. @@ -79,17 +37,8 @@ test("attributes every shipped byte to its source package", (t) => { "../../src/index.css?inline", "../../../unnamed/a.ts", ], - mappings: mappings([ - [ - [4, 0], - [11, 1], - ], - [], - [ - [0, 2], - [4, null], - ], - ]), + // 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)); @@ -103,17 +52,17 @@ test("attributes every shipped byte to its source package", (t) => { totals: { "(unattributed)": 4 + 1 + "export{a};\n".length + 4 + 4, "@acme/protocol": 'P("x");'.length, - "@acme/web": Buffer.byteLength('W("é");') + shim, + "@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/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 and rejects broken maps", (t) => { +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"); @@ -121,22 +70,10 @@ test("never attributes sources outside the repository and rejects broken maps", write(path.join(shipped, "a.js"), "abc"); write( path.join(shipped, "a.js.map"), - JSON.stringify({ - version: 3, - sources: ["../../../outside.ts"], - mappings: mappings([[[0, 0]]]), - }), + JSON.stringify({ version: 3, sources: ["../../../outside.ts"], mappings: "AAAA" }), ); assert.deepEqual( attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo }).totals, { "(unattributed)": 3 }, ); - write( - path.join(shipped, "a.js.map"), - JSON.stringify({ version: 3, sources: [], mappings: mappings([[[0, 0]]]) }), - ); - assert.throws( - () => attribution.attribute({ shippedDir: shipped, buildDir: build, repoRoot: repo }), - /missing source/, - ); });