diff --git a/.changeset/tricky-results-roll.md b/.changeset/tricky-results-roll.md new file mode 100644 index 00000000000..a79f5505021 --- /dev/null +++ b/.changeset/tricky-results-roll.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +report file size for extensions on build and dev diff --git a/packages/app/src/cli/services/build/bundle-size.test.ts b/packages/app/src/cli/services/build/bundle-size.test.ts new file mode 100644 index 00000000000..af06b225854 --- /dev/null +++ b/packages/app/src/cli/services/build/bundle-size.test.ts @@ -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('') + }) +}) diff --git a/packages/app/src/cli/services/build/bundle-size.ts b/packages/app/src/cli/services/build/bundle-size.ts new file mode 100644 index 00000000000..00edcd6a941 --- /dev/null +++ b/packages/app/src/cli/services/build/bundle-size.ts @@ -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 '' + } +} diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index d14164e17b2..97e8eca8f7a 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -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' @@ -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) + options.stdout.write(`${extension.localIdentifier} successfully built${sizeInfo}`) } type BuildFunctionExtensionOptions = ExtensionBuildOptions diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 5a760de6332..686cfb17bdb 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -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' @@ -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) }