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
5 changes: 5 additions & 0 deletions .changeset/tricky-results-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

report file size for extensions on build and dev
81 changes: 81 additions & 0 deletions packages/app/src/cli/services/build/bundle-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {getBundleSize, formatBundleSize} from './bundle-size.js'
import {describe, expect, test, vi} from 'vitest'
import {readFile} from '@shopify/cli-kit/node/fs'
import {deflate} from 'node:zlib'
import {promisify} from 'node:util'

const deflateAsync = promisify(deflate)

vi.mock('@shopify/cli-kit/node/fs')

describe('getBundleSize', () => {
test('returns raw and compressed sizes', async () => {
// Given
const content = 'a'.repeat(10000)
vi.mocked(readFile).mockResolvedValue(content as any)

// When
const result = await getBundleSize('/some/path.js')

// Then
expect(result.rawBytes).toBe(10000)
expect(result.compressedBytes).toBe((await deflateAsync(Buffer.from(content))).byteLength)
expect(result.compressedBytes).toBeLessThan(result.rawBytes)
})

test('compressed size uses deflate to match the backend (Ruby Zlib::Deflate.deflate)', async () => {
// Given
const content = JSON.stringify({key: 'value', nested: {array: [1, 2, 3]}})
vi.mocked(readFile).mockResolvedValue(content as any)

// When
const result = await getBundleSize('/some/path.js')

// Then
const expectedCompressed = (await deflateAsync(Buffer.from(content))).byteLength
expect(result.compressedBytes).toBe(expectedCompressed)
})
})

describe('formatBundleSize', () => {
test('returns formatted size string with raw and compressed sizes', async () => {
// Given
const content = 'x'.repeat(50000)
const compressedSize = (await deflateAsync(Buffer.from(content))).byteLength
vi.mocked(readFile).mockResolvedValue(content as any)

// When
const result = await formatBundleSize('/some/path.js')

// Then
const expectedRaw = (50000 / 1024).toFixed(1)
const expectedCompressed = (compressedSize / 1024).toFixed(1)
expect(result).toBe(` (${expectedRaw} KB original, ~${expectedCompressed} KB compressed)`)
})

test('formats MB for large files', async () => {
// Given
const content = 'a'.repeat(2 * 1024 * 1024)
const compressedSize = (await deflateAsync(Buffer.from(content))).byteLength
vi.mocked(readFile).mockResolvedValue(content as any)

// When
const result = await formatBundleSize('/some/path.js')

// Then
const expectedRaw = (Buffer.byteLength(content) / (1024 * 1024)).toFixed(2)
const expectedCompressed = (compressedSize / 1024).toFixed(1)
expect(result).toBe(` (${expectedRaw} MB original, ~${expectedCompressed} KB compressed)`)
})

test('returns empty string on error', async () => {
// Given
vi.mocked(readFile).mockRejectedValue(new Error('file not found'))

// When
const result = await formatBundleSize('/missing/path.js')

// Then
expect(result).toBe('')
})
})
44 changes: 44 additions & 0 deletions packages/app/src/cli/services/build/bundle-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {readFile} from '@shopify/cli-kit/node/fs'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {deflate} from 'node:zlib'
import {promisify} from 'node:util'

const deflateAsync = promisify(deflate)

/**
* Computes the raw and compressed (deflate) size of a file.
* Uses the same compression algorithm as the Shopify backend (Zlib::Deflate.deflate).
*/
export async function getBundleSize(filePath: string) {
const content = await readFile(filePath)
const rawBytes = Buffer.byteLength(content)
const compressed = await deflateAsync(Buffer.from(content))
const compressedBytes = compressed.byteLength

return {path: filePath, rawBytes, compressedBytes}
}

/**
* Formats a byte count as a human-readable string (KB or MB).
*/
function formatSize(bytes: number) {
if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
return `${(bytes / 1024).toFixed(1)} KB`
}

/**
* Returns a formatted bundle size suffix like " (21.4 KB original, ~8.3 KB compressed)".
* Returns an empty string on failure so callers can append it unconditionally.
*/
export async function formatBundleSize(filePath: string) {
try {
const {rawBytes, compressedBytes} = await getBundleSize(filePath)
return ` (${formatSize(rawBytes)} original, ~${formatSize(compressedBytes)} compressed)`
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
outputDebug(`Failed to get bundle size for ${filePath}: ${error}`)
return ''
}
}
4 changes: 3 additions & 1 deletion packages/app/src/cli/services/build/extension.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {formatBundleSize} from './bundle-size.js'
import {AppInterface} from '../../models/app/app.js'
import {bundleExtension} from '../extensions/bundle.js'
import {buildGraphqlTypes, buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js'
Expand Down Expand Up @@ -110,7 +111,8 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex

await extension.buildValidation()

options.stdout.write(`${extension.localIdentifier} successfully built`)
const sizeInfo = await formatBundleSize(extension.outputPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Suggestion: When an extension has assets, each is bundled to a separate output file via bundleExtension, but formatBundleSize only reports the size of extension.outputPath (the main entry point). If the 64 KB compressed limit applies to the total extension payload (main + assets), developers could be misled about how close they are to the limit. Worth verifying whether the backend counts total extension size or just the main bundle.

Suggestion: Consider also reporting asset sizes or a total size so developers get a complete picture. If the 64 KB limit only applies to the main bundle, a brief comment clarifying that would help future maintainers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. The main bundle JS size is checked individually, but some extensions have more files like should_render or tool files that are also validated by the backend. I think I'd like to start with reporting just the main entry point since this is the file most people seem to have issues with. We can add reporting for other files later on?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good 👍

options.stdout.write(`${extension.localIdentifier} successfully built${sizeInfo}`)
}

type BuildFunctionExtensionOptions = ExtensionBuildOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {handleWatcherEvents} from './app-event-watcher-handler.js'
import {AppLinkedInterface} from '../../../models/app/app.js'
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
import {ExtensionBuildOptions} from '../../build/extension.js'
import {formatBundleSize} from '../../build/bundle-size.js'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {joinPath} from '@shopify/cli-kit/node/path'
Expand Down Expand Up @@ -259,7 +260,8 @@ export class AppEventWatcher extends EventEmitter {
try {
if (this.esbuildManager.contexts?.[ext.uid]?.length) {
await this.esbuildManager.rebuildContext(ext)
this.options.stdout.write(`Build successful`)
const sizeInfo = await formatBundleSize(ext.outputPath)
this.options.stdout.write(`Build successful${sizeInfo}`)
} else {
await this.buildExtension(ext)
}
Expand Down
Loading