Files
upstream-tldraw/apps/dotcom/client/scripts/measure-bundle-size.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

269 lines
8.9 KiB
TypeScript

import { readFileSync, readdirSync, statSync } from 'fs'
import { extname, join } from 'path'
import { gzipSync } from 'zlib'
import { nicelog } from '../../../../internal/scripts/lib/nicelog'
import { captureCiMetricEvents, CiMetricEvent } from '../../../../internal/scripts/lib/posthog'
// How many of the largest assets we report individually alongside the totals — enough to attribute
// a jump in the total to the chunk that caused it, without sending an event per file on every
// deploy. Assets in the initial payload are always reported, however small: they're the ones we
// most want to be able to explain a change in.
const TOP_ASSET_COUNT = 25
export type AssetCategory = 'js' | 'css' | 'font' | 'image' | 'json' | 'other'
const EXTENSIONS_BY_CATEGORY: Record<Exclude<AssetCategory, 'other'>, string[]> = {
js: ['.js', '.mjs'],
css: ['.css'],
font: ['.woff2', '.woff', '.ttf', '.otf'],
image: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif', '.ico'],
// mostly the compiled translations, which are worth telling apart from everything else
json: ['.json'],
}
export const ASSET_CATEGORIES: AssetCategory[] = ['js', 'css', 'font', 'image', 'json', 'other']
export interface BundleAsset {
fileName: string
/** `fileName` with the content hash removed, so the same chunk is comparable across builds. */
name: string
category: AssetCategory
bytes: number
gzipBytes: number
/** Whether the browser downloads this asset to render the app, before any lazy loading. */
isInitial: boolean
}
export interface BundleSizeGroup {
count: number
bytes: number
gzipBytes: number
}
export interface BundleSizeReport {
assets: BundleAsset[]
total: BundleSizeGroup
initial: BundleSizeGroup
byCategory: Record<AssetCategory, BundleSizeGroup>
/**
* The initial payload split up, because its two halves move for unrelated reasons: the js grows
* when we add application code, the fonts only when we change which ones we preload.
*/
initialByCategory: Record<AssetCategory, BundleSizeGroup>
}
export function getAssetCategory(fileName: string): AssetCategory {
const extension = extname(fileName).toLowerCase()
for (const [category, extensions] of Object.entries(EXTENSIONS_BY_CATEGORY)) {
if (extensions.includes(extension)) return category as AssetCategory
}
return 'other'
}
/**
* Strip vite's content hash from an asset file name, so `index-BdIhNi0J.js` becomes `index.js` and
* the same chunk can be followed from one build to the next. Hashes are 8 url-safe base64 chars,
* which is short enough that a real name segment could look like one — but only if it's exactly 8
* characters and sits right before the extension, which is rare enough to live with.
*/
export function stripContentHash(fileName: string) {
return fileName.replace(/-[A-Za-z0-9_-]{8}(\.[A-Za-z0-9]+)$/, '$1')
}
/**
* The assets referenced directly by index.html: the entry chunk, its modulepreloads, stylesheets,
* and the fonts and sprite sheet the build script preloads. Together these are what a visitor
* downloads before anything renders, which is the number that matters most for load time.
*/
export function getInitialAssetNames(indexHtml: string) {
const names = new Set<string>()
for (const match of indexHtml.matchAll(/(?:src|href)="\/assets\/([^"?#]+)/g)) {
names.add(match[1])
}
return names
}
function emptyGroup(): BundleSizeGroup {
return { count: 0, bytes: 0, gzipBytes: 0 }
}
function addToGroup(group: BundleSizeGroup, asset: BundleAsset) {
group.count += 1
group.bytes += asset.bytes
group.gzipBytes += asset.gzipBytes
}
function emptyCategoryGroups() {
return Object.fromEntries(ASSET_CATEGORIES.map((category) => [category, emptyGroup()])) as Record<
AssetCategory,
BundleSizeGroup
>
}
export function summarizeAssets(assets: BundleAsset[]): BundleSizeReport {
const report: BundleSizeReport = {
assets,
total: emptyGroup(),
initial: emptyGroup(),
byCategory: emptyCategoryGroups(),
initialByCategory: emptyCategoryGroups(),
}
for (const asset of assets) {
addToGroup(report.total, asset)
addToGroup(report.byCategory[asset.category], asset)
if (asset.isInitial) {
addToGroup(report.initial, asset)
addToGroup(report.initialByCategory[asset.category], asset)
}
}
return report
}
/**
* Measure every file in the built assets directory. Source maps are skipped: we serve them, but
* they're only fetched when devtools is open, so counting them would swamp the numbers a visitor
* actually pays for. Files in `public/` are skipped too — they're copied verbatim rather than
* bundled, so they don't move when application code changes.
*/
export function measureBundleSize(staticDir: string): BundleSizeReport {
const assetsDir = join(staticDir, 'assets')
const initialAssetNames = getInitialAssetNames(
readFileSync(join(staticDir, 'index.html'), 'utf8')
)
const assets = readdirSync(assetsDir)
.filter(
(fileName) => !fileName.endsWith('.map') && statSync(join(assetsDir, fileName)).isFile()
)
.map((fileName): BundleAsset => {
const contents = readFileSync(join(assetsDir, fileName))
return {
fileName,
name: stripContentHash(fileName),
category: getAssetCategory(fileName),
bytes: contents.byteLength,
gzipBytes: gzipSync(contents).byteLength,
isInitial: initialAssetNames.has(fileName),
}
})
.sort((a, b) => b.gzipBytes - a.gzipBytes)
return summarizeAssets(assets)
}
function formatKb(bytes: number) {
return `${(bytes / 1024).toFixed(1)} kB`
}
export function formatBundleSizeReport(report: BundleSizeReport) {
function section(label: string, total: BundleSizeGroup, groups: Record<string, BundleSizeGroup>) {
return [
[label, total] as [string, BundleSizeGroup],
...ASSET_CATEGORIES.filter((category) => groups[category].count > 0).map(
(category): [string, BundleSizeGroup] => [` ${category}`, groups[category]]
),
]
}
const rows = [
...section('initial payload', report.initial, report.initialByCategory),
...section('all assets', report.total, report.byCategory),
]
const labelWidth = Math.max(...rows.map(([label]) => label.length))
return rows
.map(
([label, group]) =>
` ${label.padEnd(labelWidth)} ${formatKb(group.gzipBytes).padStart(10)} gzipped` +
` ${formatKb(group.bytes).padStart(10)} raw (${group.count} files)`
)
.join('\n')
}
export function getBundleSizeEvents(
report: BundleSizeReport,
context: Record<string, unknown>
): CiMetricEvent[] {
const categoryProperties: Record<string, number> = {}
for (const category of ASSET_CATEGORIES) {
for (const [prefix, groups] of [
['', report.byCategory],
['initial_', report.initialByCategory],
] as const) {
const group = groups[category]
categoryProperties[`${prefix}${category}_bytes`] = group.bytes
categoryProperties[`${prefix}${category}_gzip_bytes`] = group.gzipBytes
categoryProperties[`${prefix}${category}_count`] = group.count
}
}
return [
{
event: 'dotcom_bundle_size',
distinctId: 'dotcom-bundle-size',
properties: {
...context,
total_bytes: report.total.bytes,
total_gzip_bytes: report.total.gzipBytes,
total_count: report.total.count,
initial_bytes: report.initial.bytes,
initial_gzip_bytes: report.initial.gzipBytes,
initial_count: report.initial.count,
...categoryProperties,
},
},
...report.assets
.filter((asset, index) => asset.isInitial || index < TOP_ASSET_COUNT)
.map(
(asset): CiMetricEvent => ({
event: 'dotcom_bundle_size_asset',
distinctId: 'dotcom-bundle-size',
properties: {
...context,
asset_name: asset.name,
asset_file_name: asset.fileName,
asset_category: asset.category,
asset_is_initial: asset.isInitial,
bytes: asset.bytes,
gzip_bytes: asset.gzipBytes,
},
})
),
]
}
/**
* Measure the built client and, when `BUNDLE_SIZE_ANALYTICS_ENABLED` is set, send the results to
* PostHog so we can watch the bundle over time. Reporting never fails the build: a bundle size
* datapoint isn't worth breaking a deploy over.
*
* Call this on the finished `.vercel/output/static` directory, and before the deploy script
* coalesces previous deploys' assets into it — those older files are still served for open tabs,
* but they aren't part of this build.
*/
export async function reportBundleSize(staticDir: string) {
try {
const report = measureBundleSize(staticDir)
nicelog(`Bundle size:\n${formatBundleSizeReport(report)}`)
if (process.env.BUNDLE_SIZE_ANALYTICS_ENABLED !== 'true') {
nicelog('Bundle size analytics disabled, not reporting to PostHog')
return
}
// See https://eu.posthog.com/project/45972
await captureCiMetricEvents(
getBundleSizeEvents(report, {
git_commit: process.env.RELEASE_COMMIT_HASH || process.env.GITHUB_SHA,
git_branch: process.env.GITHUB_REF_NAME,
tldraw_env: process.env.TLDRAW_ENV,
ci_environment: process.env.GITHUB_ACTIONS ? 'github-actions' : 'local',
})
)
} catch (error) {
console.error('Failed to measure or report bundle size:', error)
}
}