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/nine-rabbits-win.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Emit esbuild metafiles for ui extensions
51 changes: 46 additions & 5 deletions packages/app/src/cli/services/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {writeManifestToBundle, compressBundle} from './bundle.js'
import {writeManifestToBundle, compressBundle, BUNDLE_EXCLUSION_PATTERNS} from './bundle.js'
import {AppInterface} from '../models/app/app.js'
import {describe, test, expect, vi} from 'vitest'
import {joinPath} from '@shopify/cli-kit/node/path'
Expand Down Expand Up @@ -53,7 +53,7 @@ describe('compressBundle', () => {
expect(zip).toHaveBeenCalledWith({
inputDirectory: inputDir,
outputZipPath: outputZip,
matchFilePattern: ['**/*', '!**/*.js.map'],
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
})
expect(brotliCompress).not.toHaveBeenCalled()
})
Expand All @@ -74,7 +74,7 @@ describe('compressBundle', () => {
// Then
expect(zip).toHaveBeenCalledWith(
expect.objectContaining({
matchFilePattern: ['**/*', '!**/*.js.map'],
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
}),
)
})
Expand All @@ -95,7 +95,7 @@ describe('compressBundle', () => {
expect(brotliCompress).toHaveBeenCalledWith({
inputDirectory: inputDir,
outputPath: outputBr,
matchFilePattern: ['**/*', '!**/*.js.map'],
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
})
expect(zip).not.toHaveBeenCalled()
})
Expand All @@ -116,7 +116,48 @@ describe('compressBundle', () => {
// Then
expect(brotliCompress).toHaveBeenCalledWith(
expect.objectContaining({
matchFilePattern: ['**/*', '!**/*.js.map'],
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
}),
)
})
})

test('excludes .metafile.json files from the zip', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const inputDir = joinPath(tmpDir, 'input')
const outputZip = joinPath(tmpDir, 'output.zip')
await mkdir(inputDir)
await writeFile(joinPath(inputDir, 'test.txt'), 'test content')
await writeFile(joinPath(inputDir, 'main.metafile.json'), '{"inputs":{},"outputs":{}}')

// When
await compressBundle(inputDir, outputZip)

// Then
expect(zip).toHaveBeenCalledWith(
expect.objectContaining({
matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS],
}),
)
})
})

test('uses custom file patterns as-is when provided', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const inputDir = joinPath(tmpDir, 'input')
const outputZip = joinPath(tmpDir, 'output.zip')
await mkdir(inputDir)
await writeFile(joinPath(inputDir, 'test.txt'), 'test content')

// When
await compressBundle(inputDir, outputZip, ['ext1/**', 'manifest.json'])

// Then
expect(zip).toHaveBeenCalledWith(
expect.objectContaining({
matchFilePattern: ['ext1/**', 'manifest.json'],
}),
)
})
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/cli/services/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export async function writeManifestToBundle(appManifest: AppManifest, bundlePath
await writeFile(manifestPath, JSON.stringify(appManifest, null, 2))
}

export const BUNDLE_EXCLUSION_PATTERNS = ['!**/*.js.map', '!**/*.metafile.json']

export async function compressBundle(inputDirectory: string, outputPath: string, customMatchFilePattern?: string[]) {
const matchFilePattern = customMatchFilePattern ?? ['**/*', '!**/*.js.map']
const matchFilePattern = customMatchFilePattern ?? ['**/*', ...BUNDLE_EXCLUSION_PATTERNS]
if (outputPath.endsWith('.br')) {
await brotliCompress({inputDirectory, outputPath, matchFilePattern})
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import {DevSessionLogger} from './dev-session-logger.js'
import {DevSessionStatusManager} from './dev-session-status-manager.js'
import {DevSessionProcessOptions} from './dev-session-process.js'
import {AppEvent, AppEventWatcher, ExtensionEvent} from '../../app-events/app-event-watcher.js'
import {compressBundle, getUploadURL, uploadToGCS, writeManifestToBundle} from '../../../bundle.js'
import {
BUNDLE_EXCLUSION_PATTERNS,
compressBundle,
getUploadURL,
uploadToGCS,
writeManifestToBundle,
} from '../../../bundle.js'
import {DevSessionCreateOptions, DevSessionUpdateOptions} from '../../../../utilities/developer-platform-client.js'
import {AppManifest} from '../../../../models/app/app.js'
import {getWebSocketUrl} from '../../extension.js'
Expand Down Expand Up @@ -376,7 +382,7 @@ export class DevSession {
)

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

await compressBundle(this.bundlePath, compressedBundlePath, filePattern)
Expand Down
86 changes: 85 additions & 1 deletion packages/app/src/cli/services/extensions/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-sp
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import {describe, expect, test, vi} from 'vitest'
import {context as esContext} from 'esbuild'
import {glob, inTemporaryDirectory, mkdir, touchFileSync} from '@shopify/cli-kit/node/fs'
import {glob, inTemporaryDirectory, mkdir, touchFileSync, writeFile} from '@shopify/cli-kit/node/fs'
import {basename, joinPath} from '@shopify/cli-kit/node/path'

vi.mock('@shopify/cli-kit/node/fs', async () => {
const actual: any = await vi.importActual('@shopify/cli-kit/node/fs')
return {
...actual,
writeFile: vi.fn(),
}
})

vi.mock('esbuild', async () => {
const esbuild: any = await vi.importActual('esbuild')
return {
Expand Down Expand Up @@ -118,6 +126,82 @@ describe('bundleExtension()', () => {
expect(plugins).toContain('shopify:deduplicate-react')
})

test('writes metafile to disk for production builds', async () => {
const extension = await testUIExtension()
const stdout: any = {
write: vi.fn(),
}
const stderr: any = {
write: vi.fn(),
}
const esbuildRebuild = vi.fn(esbuildResultFixture)

vi.mocked(esContext).mockResolvedValue({
rebuild: esbuildRebuild,
watch: vi.fn(),
dispose: vi.fn(),
cancel: vi.fn(),
serve: vi.fn(),
})

await bundleExtension({
env: {},
outputPath: extension.outputPath,
minify: true,
environment: 'production',
stdin: {
contents: 'console.log("mock stdin content")',
resolveDir: 'mock/resolve/dir',
loader: 'tsx',
},
stdout,
stderr,
})

expect(writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\.metafile\.json$/),
JSON.stringify({inputs: {}, outputs: {}}),
)
})

test('does not write metafile to disk for development builds', async () => {
const extension = await testUIExtension()
const stdout: any = {
write: vi.fn(),
}
const stderr: any = {
write: vi.fn(),
}
const esbuildRebuild = vi.fn(async () => {
const result = await esbuildResultFixture()
return {...result, metafile: undefined}
})

vi.mocked(esContext).mockResolvedValue({
rebuild: esbuildRebuild,
watch: vi.fn(),
dispose: vi.fn(),
cancel: vi.fn(),
serve: vi.fn(),
})

await bundleExtension({
env: {},
outputPath: extension.outputPath,
minify: false,
environment: 'development',
stdin: {
contents: 'console.log("mock stdin content")',
resolveDir: 'mock/resolve/dir',
loader: 'tsx',
},
stdout,
stderr,
})

expect(writeFile).not.toHaveBeenCalled()
})

test('can switch off React deduplication', async () => {
// Given
const extension = await testUIExtension()
Expand Down
19 changes: 16 additions & 3 deletions packages/app/src/cli/services/extensions/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {themeExtensionFiles} from '../../utilities/extensions/theme.js'
import {EsbuildEnvVarRegex, environmentVariableNames} from '../../constants.js'
import {context as esContext, formatMessagesSync} from 'esbuild'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {copyFile, glob} from '@shopify/cli-kit/node/fs'
import {joinPath, relativePath} from '@shopify/cli-kit/node/path'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {copyFile, glob, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath, parsePath, relativePath} from '@shopify/cli-kit/node/path'
import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output'
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
import {pickBy} from '@shopify/cli-kit/common/object'
import graphqlLoaderPlugin from '@luckycatfactory/esbuild-graphql-loader'
Expand Down Expand Up @@ -58,6 +58,16 @@ export async function bundleExtension(options: BundleOptions, processEnv = proce
const context = await esContext(esbuildOptions)
const result = await context.rebuild()
onResult(result, options)
if (result.metafile) {
const {dir, name} = parsePath(options.outputPath)
const metafilePath = joinPath(dir, `${name}.metafile.json`)
try {
await writeFile(metafilePath, JSON.stringify(result.metafile))
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
outputWarn(`Failed to write metafile to ${metafilePath}: ${error}`)
}
}
await context.dispose()
}

Expand Down Expand Up @@ -154,6 +164,9 @@ export function getESBuildOptions(options: BundleOptions, processEnv = process.e
esbuildOptions.sourcemap = true
esbuildOptions.sourceRoot = `${options.stdin.resolveDir}/src`
}
if (options.environment === 'production') {
Copy link
Member Author

Choose a reason for hiding this comment

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

Question for reviewers. Do we even ever run this not in production mode? Could we just always emit the metafile?

Copy link
Member Author

Choose a reason for hiding this comment

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

Should we actually make this a feature like generates_source_maps?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes to the production question, check the callers of getESBuildOptions, when called from app-watcher-esbuild it is called in development mode, because that's from the dev session flow.
Do we want to generate metafiles only when deploying? or also when deving? is there any performance hit by doing it?

I don't think we need to convert this into a feature, it makes sense to want to this for every esbuild extension.

Copy link
Member Author

@robin-drexler robin-drexler Feb 25, 2026

Choose a reason for hiding this comment

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

Do we want to generate metafiles only when deploying?

I think only doing it for prod builds is ok for now.

esbuildOptions.metafile = true
}
return esbuildOptions
}

Expand Down
11 changes: 11 additions & 0 deletions packages/cli-kit/src/public/node/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolve,
basename as basenamePathe,
extname as extnamePathe,
parse,
isAbsolute,
} from 'pathe'
import {fileURLToPath} from 'url'
Expand Down Expand Up @@ -95,6 +96,16 @@ export function extname(path: string): string {
return extnamePathe(path)
}

/**
* Parses a path into its components (root, dir, base, ext, name).
*
* @param path - Path to parse.
* @returns Parsed path object.
*/
export function parsePath(path: string): {root: string; dir: string; base: string; ext: string; name: string} {
return parse(path)
}

/**
* Given an absolute filesystem path, it makes it relative to
* the current working directory. This is useful when logging paths
Expand Down
Loading