Skip to content

Commit 8658f96

Browse files
Merge pull request #7205 from Shopify/rd/bundle-size-reporting
[Feature] report file size for ui extensions on build and dev
2 parents 814a3b9 + dbcdf9f commit 8658f96

5 files changed

Lines changed: 136 additions & 2 deletions

File tree

.changeset/tricky-results-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': minor
3+
---
4+
5+
report file size for extensions on build and dev
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {getBundleSize, formatBundleSize} from './bundle-size.js'
2+
import {describe, expect, test, vi} from 'vitest'
3+
import {readFile} from '@shopify/cli-kit/node/fs'
4+
import {deflate} from 'node:zlib'
5+
import {promisify} from 'node:util'
6+
7+
const deflateAsync = promisify(deflate)
8+
9+
vi.mock('@shopify/cli-kit/node/fs')
10+
11+
describe('getBundleSize', () => {
12+
test('returns raw and compressed sizes', async () => {
13+
// Given
14+
const content = 'a'.repeat(10000)
15+
vi.mocked(readFile).mockResolvedValue(content as any)
16+
17+
// When
18+
const result = await getBundleSize('/some/path.js')
19+
20+
// Then
21+
expect(result.rawBytes).toBe(10000)
22+
expect(result.compressedBytes).toBe((await deflateAsync(Buffer.from(content))).byteLength)
23+
expect(result.compressedBytes).toBeLessThan(result.rawBytes)
24+
})
25+
26+
test('compressed size uses deflate to match the backend (Ruby Zlib::Deflate.deflate)', async () => {
27+
// Given
28+
const content = JSON.stringify({key: 'value', nested: {array: [1, 2, 3]}})
29+
vi.mocked(readFile).mockResolvedValue(content as any)
30+
31+
// When
32+
const result = await getBundleSize('/some/path.js')
33+
34+
// Then
35+
const expectedCompressed = (await deflateAsync(Buffer.from(content))).byteLength
36+
expect(result.compressedBytes).toBe(expectedCompressed)
37+
})
38+
})
39+
40+
describe('formatBundleSize', () => {
41+
test('returns formatted size string with raw and compressed sizes', async () => {
42+
// Given
43+
const content = 'x'.repeat(50000)
44+
const compressedSize = (await deflateAsync(Buffer.from(content))).byteLength
45+
vi.mocked(readFile).mockResolvedValue(content as any)
46+
47+
// When
48+
const result = await formatBundleSize('/some/path.js')
49+
50+
// Then
51+
const expectedRaw = (50000 / 1024).toFixed(1)
52+
const expectedCompressed = (compressedSize / 1024).toFixed(1)
53+
expect(result).toBe(` (${expectedRaw} KB original, ~${expectedCompressed} KB compressed)`)
54+
})
55+
56+
test('formats MB for large files', async () => {
57+
// Given
58+
const content = 'a'.repeat(2 * 1024 * 1024)
59+
const compressedSize = (await deflateAsync(Buffer.from(content))).byteLength
60+
vi.mocked(readFile).mockResolvedValue(content as any)
61+
62+
// When
63+
const result = await formatBundleSize('/some/path.js')
64+
65+
// Then
66+
const expectedRaw = (Buffer.byteLength(content) / (1024 * 1024)).toFixed(2)
67+
const expectedCompressed = (compressedSize / 1024).toFixed(1)
68+
expect(result).toBe(` (${expectedRaw} MB original, ~${expectedCompressed} KB compressed)`)
69+
})
70+
71+
test('returns empty string on error', async () => {
72+
// Given
73+
vi.mocked(readFile).mockRejectedValue(new Error('file not found'))
74+
75+
// When
76+
const result = await formatBundleSize('/missing/path.js')
77+
78+
// Then
79+
expect(result).toBe('')
80+
})
81+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {readFile} from '@shopify/cli-kit/node/fs'
2+
import {outputDebug} from '@shopify/cli-kit/node/output'
3+
import {deflate} from 'node:zlib'
4+
import {promisify} from 'node:util'
5+
6+
const deflateAsync = promisify(deflate)
7+
8+
/**
9+
* Computes the raw and compressed (deflate) size of a file.
10+
* Uses the same compression algorithm as the Shopify backend (Zlib::Deflate.deflate).
11+
*/
12+
export async function getBundleSize(filePath: string) {
13+
const content = await readFile(filePath)
14+
const rawBytes = Buffer.byteLength(content)
15+
const compressed = await deflateAsync(Buffer.from(content))
16+
const compressedBytes = compressed.byteLength
17+
18+
return {path: filePath, rawBytes, compressedBytes}
19+
}
20+
21+
/**
22+
* Formats a byte count as a human-readable string (KB or MB).
23+
*/
24+
function formatSize(bytes: number) {
25+
if (bytes >= 1024 * 1024) {
26+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
27+
}
28+
return `${(bytes / 1024).toFixed(1)} KB`
29+
}
30+
31+
/**
32+
* Returns a formatted bundle size suffix like " (21.4 KB original, ~8.3 KB compressed)".
33+
* Returns an empty string on failure so callers can append it unconditionally.
34+
*/
35+
export async function formatBundleSize(filePath: string) {
36+
try {
37+
const {rawBytes, compressedBytes} = await getBundleSize(filePath)
38+
return ` (${formatSize(rawBytes)} original, ~${formatSize(compressedBytes)} compressed)`
39+
// eslint-disable-next-line no-catch-all/no-catch-all
40+
} catch (error) {
41+
outputDebug(`Failed to get bundle size for ${filePath}: ${error}`)
42+
return ''
43+
}
44+
}

packages/app/src/cli/services/build/extension.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {formatBundleSize} from './bundle-size.js'
12
import {AppInterface} from '../../models/app/app.js'
23
import {bundleExtension} from '../extensions/bundle.js'
34
import {buildGraphqlTypes, buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js'
@@ -110,7 +111,8 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
110111

111112
await extension.buildValidation()
112113

113-
options.stdout.write(`${extension.localIdentifier} successfully built`)
114+
const sizeInfo = await formatBundleSize(extension.outputPath)
115+
options.stdout.write(`${extension.localIdentifier} successfully built${sizeInfo}`)
114116
}
115117

116118
type BuildFunctionExtensionOptions = ExtensionBuildOptions

packages/app/src/cli/services/dev/app-events/app-event-watcher.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {handleWatcherEvents} from './app-event-watcher-handler.js'
55
import {AppLinkedInterface} from '../../../models/app/app.js'
66
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
77
import {ExtensionBuildOptions} from '../../build/extension.js'
8+
import {formatBundleSize} from '../../build/bundle-size.js'
89
import {outputDebug} from '@shopify/cli-kit/node/output'
910
import {AbortSignal} from '@shopify/cli-kit/node/abort'
1011
import {joinPath} from '@shopify/cli-kit/node/path'
@@ -259,7 +260,8 @@ export class AppEventWatcher extends EventEmitter {
259260
try {
260261
if (this.esbuildManager.contexts?.[ext.uid]?.length) {
261262
await this.esbuildManager.rebuildContext(ext)
262-
this.options.stdout.write(`Build successful`)
263+
const sizeInfo = await formatBundleSize(ext.outputPath)
264+
this.options.stdout.write(`Build successful${sizeInfo}`)
263265
} else {
264266
await this.buildExtension(ext)
265267
}

0 commit comments

Comments
 (0)