Skip to content

Commit 6340b9d

Browse files
Merge pull request #6880 from Shopify/rd/metafile
[Feature] emit metafile for ui extensions to allow for bundle analyzation
2 parents 59efb72 + 9ca669c commit 6340b9d

7 files changed

Lines changed: 195 additions & 13 deletions

File tree

.changeset/nine-rabbits-win.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+
Emit esbuild metafiles for ui extensions

packages/app/src/cli/services/bundle.test.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {writeManifestToBundle, compressBundle} from './bundle.js'
1+
import {writeManifestToBundle, compressBundle, BUNDLE_EXCLUSION_PATTERNS} from './bundle.js'
22
import {AppInterface} from '../models/app/app.js'
33
import {describe, test, expect, vi} from 'vitest'
44
import {joinPath} from '@shopify/cli-kit/node/path'
@@ -53,7 +53,7 @@ describe('compressBundle', () => {
5353
expect(zip).toHaveBeenCalledWith({
5454
inputDirectory: inputDir,
5555
outputZipPath: outputZip,
56-
matchFilePattern: ['**/*', '!**/*.js.map'],
56+
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
5757
})
5858
expect(brotliCompress).not.toHaveBeenCalled()
5959
})
@@ -74,7 +74,7 @@ describe('compressBundle', () => {
7474
// Then
7575
expect(zip).toHaveBeenCalledWith(
7676
expect.objectContaining({
77-
matchFilePattern: ['**/*', '!**/*.js.map'],
77+
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
7878
}),
7979
)
8080
})
@@ -95,7 +95,7 @@ describe('compressBundle', () => {
9595
expect(brotliCompress).toHaveBeenCalledWith({
9696
inputDirectory: inputDir,
9797
outputPath: outputBr,
98-
matchFilePattern: ['**/*', '!**/*.js.map'],
98+
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
9999
})
100100
expect(zip).not.toHaveBeenCalled()
101101
})
@@ -116,7 +116,48 @@ describe('compressBundle', () => {
116116
// Then
117117
expect(brotliCompress).toHaveBeenCalledWith(
118118
expect.objectContaining({
119-
matchFilePattern: ['**/*', '!**/*.js.map'],
119+
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
120+
}),
121+
)
122+
})
123+
})
124+
125+
test('excludes .metafile.json files from the zip', async () => {
126+
await inTemporaryDirectory(async (tmpDir) => {
127+
// Given
128+
const inputDir = joinPath(tmpDir, 'input')
129+
const outputZip = joinPath(tmpDir, 'output.zip')
130+
await mkdir(inputDir)
131+
await writeFile(joinPath(inputDir, 'test.txt'), 'test content')
132+
await writeFile(joinPath(inputDir, 'main.metafile.json'), '{"inputs":{},"outputs":{}}')
133+
134+
// When
135+
await compressBundle(inputDir, outputZip)
136+
137+
// Then
138+
expect(zip).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
141+
}),
142+
)
143+
})
144+
})
145+
146+
test('uses custom file patterns as-is when provided', async () => {
147+
await inTemporaryDirectory(async (tmpDir) => {
148+
// Given
149+
const inputDir = joinPath(tmpDir, 'input')
150+
const outputZip = joinPath(tmpDir, 'output.zip')
151+
await mkdir(inputDir)
152+
await writeFile(joinPath(inputDir, 'test.txt'), 'test content')
153+
154+
// When
155+
await compressBundle(inputDir, outputZip, ['ext1/**', 'manifest.json'])
156+
157+
// Then
158+
expect(zip).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
matchFilePattern: ['ext1/**', 'manifest.json'],
120161
}),
121162
)
122163
})

packages/app/src/cli/services/bundle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export async function writeManifestToBundle(appManifest: AppManifest, bundlePath
1414
await writeFile(manifestPath, JSON.stringify(appManifest, null, 2))
1515
}
1616

17+
export const BUNDLE_EXCLUSION_PATTERNS = ['!**/*.js.map', '!**/*.metafile.json']
18+
1719
export async function compressBundle(inputDirectory: string, outputPath: string, customMatchFilePattern?: string[]) {
18-
const matchFilePattern = customMatchFilePattern ?? ['**/*', '!**/*.js.map']
20+
const matchFilePattern = customMatchFilePattern ?? ['**/*', ...BUNDLE_EXCLUSION_PATTERNS]
1921
if (outputPath.endsWith('.br')) {
2022
await brotliCompress({inputDirectory, outputPath, matchFilePattern})
2123
} else {

packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import {DevSessionLogger} from './dev-session-logger.js'
22
import {DevSessionStatusManager} from './dev-session-status-manager.js'
33
import {DevSessionProcessOptions} from './dev-session-process.js'
44
import {AppEvent, AppEventWatcher, ExtensionEvent} from '../../app-events/app-event-watcher.js'
5-
import {compressBundle, getUploadURL, uploadToGCS, writeManifestToBundle} from '../../../bundle.js'
5+
import {
6+
BUNDLE_EXCLUSION_PATTERNS,
7+
compressBundle,
8+
getUploadURL,
9+
uploadToGCS,
10+
writeManifestToBundle,
11+
} from '../../../bundle.js'
612
import {DevSessionCreateOptions, DevSessionUpdateOptions} from '../../../../utilities/developer-platform-client.js'
713
import {AppManifest} from '../../../../models/app/app.js'
814
import {getWebSocketUrl} from '../../extension.js'
@@ -376,7 +382,7 @@ export class DevSession {
376382
)
377383

378384
// Create zip file with everything
379-
const filePattern = [...assets.map((ext) => `${ext}/**`), '!**/*.js.map']
385+
const filePattern = [...assets.map((ext) => `${ext}/**`), ...BUNDLE_EXCLUSION_PATTERNS]
380386
if (includeManifest) filePattern.push('manifest.json')
381387

382388
await compressBundle(this.bundlePath, compressedBundlePath, filePattern)

packages/app/src/cli/services/extensions/bundle.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-sp
44
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
55
import {describe, expect, test, vi} from 'vitest'
66
import {context as esContext} from 'esbuild'
7-
import {glob, inTemporaryDirectory, mkdir, touchFileSync} from '@shopify/cli-kit/node/fs'
7+
import {glob, inTemporaryDirectory, mkdir, touchFileSync, writeFile} from '@shopify/cli-kit/node/fs'
88
import {basename, joinPath} from '@shopify/cli-kit/node/path'
99

10+
vi.mock('@shopify/cli-kit/node/fs', async () => {
11+
const actual: any = await vi.importActual('@shopify/cli-kit/node/fs')
12+
return {
13+
...actual,
14+
writeFile: vi.fn(),
15+
}
16+
})
17+
1018
vi.mock('esbuild', async () => {
1119
const esbuild: any = await vi.importActual('esbuild')
1220
return {
@@ -118,6 +126,94 @@ describe('bundleExtension()', () => {
118126
expect(plugins).toContain('shopify:deduplicate-react')
119127
})
120128

129+
test('writes metafile to disk for production builds', async () => {
130+
// Given
131+
const extension = await testUIExtension()
132+
const stdout: any = {
133+
write: vi.fn(),
134+
}
135+
const stderr: any = {
136+
write: vi.fn(),
137+
}
138+
const esbuildRebuild = vi.fn(esbuildResultFixture)
139+
140+
vi.mocked(esContext).mockResolvedValue({
141+
rebuild: esbuildRebuild,
142+
watch: vi.fn(),
143+
dispose: vi.fn(),
144+
cancel: vi.fn(),
145+
serve: vi.fn(),
146+
})
147+
148+
// When
149+
await bundleExtension({
150+
env: {},
151+
outputPath: extension.outputPath,
152+
minify: true,
153+
environment: 'production',
154+
stdin: {
155+
contents: 'console.log("mock stdin content")',
156+
resolveDir: 'mock/resolve/dir',
157+
loader: 'tsx',
158+
},
159+
stdout,
160+
stderr,
161+
})
162+
163+
// Then
164+
const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0]
165+
expect(esbuildOptions.metafile).toBe(true)
166+
167+
expect(writeFile).toHaveBeenCalledWith(
168+
joinPath(extension.directory, 'dist', 'test-ui-extension.metafile.json'),
169+
JSON.stringify({inputs: {}, outputs: {}}),
170+
)
171+
})
172+
173+
test('does not write metafile to disk for development builds', async () => {
174+
// Given
175+
const extension = await testUIExtension()
176+
const stdout: any = {
177+
write: vi.fn(),
178+
}
179+
const stderr: any = {
180+
write: vi.fn(),
181+
}
182+
const esbuildRebuild = vi.fn(async () => {
183+
const result = await esbuildResultFixture()
184+
return {...result, metafile: undefined}
185+
})
186+
187+
vi.mocked(esContext).mockResolvedValue({
188+
rebuild: esbuildRebuild,
189+
watch: vi.fn(),
190+
dispose: vi.fn(),
191+
cancel: vi.fn(),
192+
serve: vi.fn(),
193+
})
194+
195+
// When
196+
await bundleExtension({
197+
env: {},
198+
outputPath: extension.outputPath,
199+
minify: false,
200+
environment: 'development',
201+
stdin: {
202+
contents: 'console.log("mock stdin content")',
203+
resolveDir: 'mock/resolve/dir',
204+
loader: 'tsx',
205+
},
206+
stdout,
207+
stderr,
208+
})
209+
210+
// Then
211+
const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0]
212+
expect(esbuildOptions.metafile).toBeUndefined()
213+
214+
expect(writeFile).not.toHaveBeenCalled()
215+
})
216+
121217
test('can switch off React deduplication', async () => {
122218
// Given
123219
const extension = await testUIExtension()

packages/app/src/cli/services/extensions/bundle.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import {themeExtensionFiles} from '../../utilities/extensions/theme.js'
44
import {EsbuildEnvVarRegex, environmentVariableNames} from '../../constants.js'
55
import {context as esContext, formatMessagesSync} from 'esbuild'
66
import {AbortSignal} from '@shopify/cli-kit/node/abort'
7-
import {copyFile, glob} from '@shopify/cli-kit/node/fs'
8-
import {joinPath, relativePath} from '@shopify/cli-kit/node/path'
9-
import {outputDebug} from '@shopify/cli-kit/node/output'
7+
import {copyFile, glob, writeFile} from '@shopify/cli-kit/node/fs'
8+
import {joinPath, parsePath, relativePath} from '@shopify/cli-kit/node/path'
9+
import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output'
1010
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
1111
import {pickBy} from '@shopify/cli-kit/common/object'
1212
import graphqlLoaderPlugin from '@luckycatfactory/esbuild-graphql-loader'
1313
import {Writable} from 'stream'
1414
import type {StdinOptions, build as esBuild, Plugin} from 'esbuild'
1515

16+
type EsbuildResult = Awaited<ReturnType<typeof esBuild>>
17+
1618
interface BundleOptions {
1719
minify: boolean
1820
env: {[variable: string]: string}
@@ -58,6 +60,9 @@ export async function bundleExtension(options: BundleOptions, processEnv = proce
5860
const context = await esContext(esbuildOptions)
5961
const result = await context.rebuild()
6062
onResult(result, options)
63+
64+
await writeMetafile(result, options.outputPath)
65+
6166
await context.dispose()
6267
}
6368

@@ -104,7 +109,20 @@ export async function copyFilesForExtension(
104109
options.stdout.write(`${extension.localIdentifier} successfully built`)
105110
}
106111

107-
function onResult(result: Awaited<ReturnType<typeof esBuild>> | null, options: BundleOptions) {
112+
async function writeMetafile(result: EsbuildResult | null, outputPath: string) {
113+
if (!result?.metafile) return
114+
115+
const {dir, name} = parsePath(outputPath)
116+
const metafilePath = joinPath(dir, `${name}.metafile.json`)
117+
try {
118+
await writeFile(metafilePath, JSON.stringify(result.metafile))
119+
// eslint-disable-next-line no-catch-all/no-catch-all
120+
} catch (error) {
121+
outputWarn(`Failed to write metafile to ${metafilePath}: ${error}`)
122+
}
123+
}
124+
125+
function onResult(result: EsbuildResult | null, options: BundleOptions) {
108126
const warnings = result?.warnings ?? []
109127
const errors = result?.errors ?? []
110128
if (warnings.length > 0) {
@@ -154,6 +172,9 @@ export function getESBuildOptions(options: BundleOptions, processEnv = process.e
154172
esbuildOptions.sourcemap = true
155173
esbuildOptions.sourceRoot = `${options.stdin.resolveDir}/src`
156174
}
175+
if (options.environment === 'production') {
176+
esbuildOptions.metafile = true
177+
}
157178
return esbuildOptions
158179
}
159180

packages/cli-kit/src/public/node/path.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resolve,
88
basename as basenamePathe,
99
extname as extnamePathe,
10+
parse,
1011
isAbsolute,
1112
} from 'pathe'
1213
import {fileURLToPath} from 'url'
@@ -95,6 +96,16 @@ export function extname(path: string): string {
9596
return extnamePathe(path)
9697
}
9798

99+
/**
100+
* Parses a path into its components (root, dir, base, ext, name).
101+
*
102+
* @param path - Path to parse.
103+
* @returns Parsed path object.
104+
*/
105+
export function parsePath(path: string): {root: string; dir: string; base: string; ext: string; name: string} {
106+
return parse(path)
107+
}
108+
98109
/**
99110
* Given an absolute filesystem path, it makes it relative to
100111
* the current working directory. This is useful when logging paths

0 commit comments

Comments
 (0)