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
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,11 @@ describe('build', async () => {
await extension.copyIntoBundle(options, bundleDirectory, 'uuid')

// Then
const outputTomlPath = joinPath(extension.outputPath, 'shopify.extension.toml')
const bundleOutputPath = extension.getOutputPathForDirectory(bundleDirectory, 'uuid')
const outputTomlPath = joinPath(bundleOutputPath, 'shopify.extension.toml')
expect(fileExistsSync(outputTomlPath)).toBe(false)

const outputProductPath = joinPath(extension.outputPath, 'blocks', 'product.liquid')
const outputProductPath = joinPath(bundleOutputPath, 'blocks', 'product.liquid')
expect(fileExistsSync(outputProductPath)).toBe(true)
})
})
Expand Down
31 changes: 14 additions & 17 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {constantize, slugify} from '@shopify/cli-kit/common/string'
import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto'
import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {joinPath, normalizePath, resolvePath, relativePath, basename} from '@shopify/cli-kit/node/path'
import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
import {fileExists, moveFile, glob, copyFile, globSync, touchFile} from '@shopify/cli-kit/node/fs'
import {getPathValue} from '@shopify/cli-kit/common/object'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {
Expand Down Expand Up @@ -46,7 +46,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
directory: string
configuration: TConfiguration
configurationPath: string
outputPath: string
readonly outputPath: string
handle: string
specification: ExtensionSpecification
uid: string
Expand Down Expand Up @@ -337,29 +337,26 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
}

async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
await this.build(options)
const bundleOutputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
await this.build({...options, bundleOutputPath})

const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
await this.keepBuiltSourcemapsLocally(bundleInputPath)
}

async copyIntoBundle(options: ExtensionBuildOptions, bundleDirectory: string, extensionUuid?: string) {
const defaultOutputPath = this.outputPath

this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)

const buildMode = this.specification.buildConfig.mode
const bundleOutputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)

if (this.isThemeExtension) {
await bundleThemeExtension(this, options)
} else if (buildMode !== 'none') {
outputDebug(`Will copy pre-built file from ${defaultOutputPath} to ${this.outputPath}`)
if (await fileExists(defaultOutputPath)) {
await copyFile(defaultOutputPath, this.outputPath)

if (buildMode === 'function') {
await bundleFunctionExtension(this.outputPath, this.outputPath)
await bundleThemeExtension(this, {...options, bundleOutputPath})
Comment on lines 346 to +351
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

In copyIntoBundle, touchFile(bundleOutputPath) assumes the bundle output is a file path. For specs where outputRelativePath is empty (common for contract-based modules), getOutputPathForDirectory returns a directory path; touching it will create a file at that path and can break the subsequent directory copy. Consider removing touchFile here (fs-extra copy creates parent dirs), or switching to ensuring a directory when bundleOutputPath is a directory.

Copilot uses AI. Check for mistakes.
} else if (this.specification.buildConfig.mode !== 'none') {
outputDebug(`Copying pre-built output from ${this.outputPath} to ${bundleOutputPath}`)
if (await fileExists(this.outputPath)) {
await touchFile(bundleOutputPath)
await copyFile(this.outputPath, bundleOutputPath)

if (this.isFunctionExtension) {
await bundleFunctionExtension(bundleOutputPath)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,12 @@ const functionSpec = createExtensionSpecification({
}
},
preDeployValidation: async (extension) => {
const wasmExists = await fileExists(extension.outputPath)
const outputPath = extension.configuration.build?.path ?? extension.outputRelativePath
const fullPath = joinPath(extension.directory, outputPath)
const wasmExists = await fileExists(fullPath)
if (!wasmExists) {
throw new AbortError(
outputContent`The function extension "${extension.handle}" hasn't compiled the wasm in the expected path: ${extension.outputPath}`,
outputContent`The function extension "${extension.handle}" hasn't compiled the wasm in the expected path: ${fullPath}`,
`Make sure the build command outputs the wasm in the expected directory.`,
)
}
Expand Down
67 changes: 49 additions & 18 deletions packages/app/src/cli/services/build/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import {exec} from '@shopify/cli-kit/node/system'
import lockfile from 'proper-lockfile'
import {AbortError} from '@shopify/cli-kit/node/error'
import {fileExistsSync, touchFile, writeFile} from '@shopify/cli-kit/node/fs'
import {copyFile, fileExistsSync, touchFile, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'

vi.mock('@shopify/cli-kit/node/system')
Expand Down Expand Up @@ -106,13 +106,17 @@
).resolves.toBeUndefined()

// Then
expect(buildJSFunction).toHaveBeenCalledWith(extension, {
stdout,
stderr,
signal,
app,
environment: 'production',
})
expect(buildJSFunction).toHaveBeenCalledWith(
extension,
{
stdout,
stderr,
signal,
app,
environment: 'production',
},
joinPath(extension.directory, 'dist/index.wasm'),
)
expect(releaseLock).toHaveBeenCalled()
})

Expand Down Expand Up @@ -243,13 +247,17 @@
).resolves.toBeUndefined()

// Then
expect(buildJSFunction).toHaveBeenCalledWith(extension, {
stdout,
stderr,
signal,
app,
environment: 'production',
})
expect(buildJSFunction).toHaveBeenCalledWith(
extension,
{
stdout,
stderr,
signal,
app,
environment: 'production',
},
joinPath(extension.directory, 'dist', 'index.wasm'),
)
expect(releaseLock).toHaveBeenCalled()
// wasm_opt should not be called when build config is undefined
expect(runWasmOpt).not.toHaveBeenCalled()
Expand Down Expand Up @@ -418,7 +426,7 @@
expect(runWasmOpt).toHaveBeenCalled()
})

test('does not rebundle when build.path stays in the default output directory', async () => {
test('copies raw binary when build.path differs from default output path', async () => {
// Given
extension.configuration.build!.path = 'dist/custom.wasm'
vi.mocked(fileExistsSync).mockReturnValue(true)
Expand All @@ -435,8 +443,31 @@
).resolves.toBeUndefined()

// Then
expect(fileExistsSync).toHaveBeenCalledWith(joinPath(extension.directory, 'dist/custom.wasm'))
expect(touchFile).not.toHaveBeenCalled()
const buildOutputPath = joinPath(extension.directory, 'dist/custom.wasm')
const canonicalOutputPath = joinPath(extension.directory, 'dist', 'index.wasm')
expect(fileExistsSync).toHaveBeenCalledWith(buildOutputPath)
expect(touchFile).toHaveBeenCalledWith(canonicalOutputPath)

Check failure on line 449 in packages/app/src/cli/services/build/extension.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 24.1.0 in macos-latest

src/cli/services/build/extension.test.ts > buildFunctionExtension > copies raw binary when build.path differs from default output path

AssertionError: expected "touchFile" to be called with arguments: [ Array(1) ] Number of calls: 0 ❯ src/cli/services/build/extension.test.ts:449:23

Check failure on line 449 in packages/app/src/cli/services/build/extension.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 22.2.0 in windows-latest (shard 1/2)

src/cli/services/build/extension.test.ts > buildFunctionExtension > copies raw binary when build.path differs from default output path

AssertionError: expected "touchFile" to be called with arguments: [ Array(1) ] Number of calls: 0 ❯ src/cli/services/build/extension.test.ts:449:23

Check failure on line 449 in packages/app/src/cli/services/build/extension.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 22.2.0 in ubuntu-latest

src/cli/services/build/extension.test.ts > buildFunctionExtension > copies raw binary when build.path differs from default output path

AssertionError: expected "touchFile" to be called with arguments: [ Array(1) ] Number of calls: 0 ❯ src/cli/services/build/extension.test.ts:449:23
expect(copyFile).toHaveBeenCalledWith(buildOutputPath, canonicalOutputPath)
// Must NOT base64-encode during build — only raw binary copy
expect(writeFile).not.toHaveBeenCalled()
})

test('does not mutate extension.outputPath', async () => {
// Given
extension.configuration.build!.path = 'target/wasm32-wasi/release/func.wasm'
vi.mocked(fileExistsSync).mockReturnValue(true)
const originalOutputPath = extension.outputPath

// When
await buildFunctionExtension(extension, {
stdout,
stderr,
signal,
app,
environment: 'production',
})

// Then
expect(extension.outputPath).toBe(originalOutputPath)
})
})
58 changes: 34 additions & 24 deletions packages/app/src/cli/services/build/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
import lockfile from 'proper-lockfile'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
import {copyFile, readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
import {Writable} from 'stream'

export interface ExtensionBuildOptions {
Expand Down Expand Up @@ -53,6 +53,14 @@ export interface ExtensionBuildOptions {
* The URL where the app is running.
*/
appURL?: string

/**
* When building for a deploy or dev bundle, this is the output path inside the
* bundle directory. When set, build functions write their final artifact here
* instead of extension.outputPath. This avoids mutating extension.outputPath at
* runtime.
Comment on lines +59 to +61
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The new bundleOutputPath docstring says “build functions write their final artifact here instead of extension.outputPath”, but buildFunctionExtension still writes to extension.outputPath (and only uses bundleOutputPath for bundling/base64 output). Please adjust the comment to match the actual behavior (or update the implementation to truly redirect build output when bundleOutputPath is set).

Suggested change
* bundle directory. When set, build functions write their final artifact here
* instead of extension.outputPath. This avoids mutating extension.outputPath at
* runtime.
* bundle directory used for bundled output artifacts.
*
* Some build flows may still write their final artifact to extension.outputPath,
* so this option should not be treated as a universal replacement for that path.

Copilot uses AI. Check for mistakes.
*/
bundleOutputPath?: string
}

/**
Expand All @@ -66,12 +74,13 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
env.APP_URL = options.appURL
}

const outputPath = options.bundleOutputPath ?? extension.outputPath
const {main, assets} = extension.getBundleExtensionStdinContent()

try {
await bundleExtension({
minify: true,
outputPath: extension.outputPath,
outputPath,
stdin: {
contents: main,
resolveDir: extension.directory,
Expand All @@ -88,7 +97,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
assets.map(async (asset) => {
await bundleExtension({
minify: true,
outputPath: joinPath(dirname(extension.outputPath), asset.outputFileName),
outputPath: joinPath(dirname(outputPath), asset.outputFileName),
stdin: {
contents: asset.content,
resolveDir: extension.directory,
Expand All @@ -111,7 +120,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex

await extension.buildValidation()

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

Expand Down Expand Up @@ -140,33 +149,31 @@ export async function buildFunctionExtension(
}

try {
const bundlePath = extension.outputPath
const relativeBuildPath =
(extension as ExtensionInstance<FunctionConfigType>).configuration.build?.path ?? extension.outputRelativePath

extension.outputPath = joinPath(extension.directory, relativeBuildPath)
const buildOutputPath = joinPath(extension.directory, relativeBuildPath)

if (extension.isJavaScript) {
await runCommandOrBuildJSFunction(extension, options)
await runCommandOrBuildJSFunction(extension, options, buildOutputPath)
} else {
await buildOtherFunction(extension, options)
}

const wasmOpt = (extension as ExtensionInstance<FunctionConfigType>).configuration.build?.wasm_opt
if (fileExistsSync(extension.outputPath) && wasmOpt) {
await runWasmOpt(extension.outputPath)
if (fileExistsSync(buildOutputPath) && wasmOpt) {
await runWasmOpt(buildOutputPath)
}

if (fileExistsSync(extension.outputPath)) {
await runTrampoline(extension.outputPath)
if (fileExistsSync(buildOutputPath)) {
await runTrampoline(buildOutputPath)
}

if (
fileExistsSync(extension.outputPath) &&
bundlePath !== extension.outputPath &&
dirname(bundlePath) !== dirname(extension.outputPath)
) {
await bundleFunctionExtension(extension.outputPath, bundlePath)
// When building for a bundle, copy + base64-encode into the bundle directory.
// This mirrors how buildUIExtension writes directly to bundleOutputPath via esbuild.
if (options.bundleOutputPath && fileExistsSync(buildOutputPath)) {
await touchFile(options.bundleOutputPath)
await copyFile(buildOutputPath, options.bundleOutputPath)
await bundleFunctionExtension(options.bundleOutputPath)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
Expand All @@ -188,21 +195,24 @@ export async function buildFunctionExtension(
}
}

export async function bundleFunctionExtension(wasmPath: string, bundlePath: string) {
outputDebug(`Converting WASM from ${wasmPath} to base64 in ${bundlePath}`)
export async function bundleFunctionExtension(wasmPath: string) {
outputDebug(`Converting WASM to base64 in ${wasmPath}`)
const base64Contents = await readFile(wasmPath, {encoding: 'base64'})
await touchFile(bundlePath)
await writeFile(bundlePath, base64Contents)
await writeFile(wasmPath, base64Contents)
}

async function runCommandOrBuildJSFunction(extension: ExtensionInstance, options: BuildFunctionExtensionOptions) {
async function runCommandOrBuildJSFunction(
extension: ExtensionInstance,
options: BuildFunctionExtensionOptions,
buildOutputPath: string,
) {
if (extension.buildCommand) {
if (extension.typegenCommand) {
await buildGraphqlTypes(extension, options)
}
return runCommand(extension.buildCommand, extension, options)
} else {
return buildJSFunction(extension as ExtensionInstance<FunctionConfigType>, options)
return buildJSFunction(extension as ExtensionInstance<FunctionConfigType>, options, buildOutputPath)
}
}

Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/cli/services/extensions/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,8 @@ describe('bundleExtension()', () => {
specification,
})

const outputPath = joinPath(tmpDir, 'dist')
await mkdir(outputPath)
themeExtension.outputPath = outputPath
const bundleOutputPath = joinPath(tmpDir, 'dist')
await mkdir(bundleOutputPath)

const app = testApp({
directory: '/project',
Expand Down Expand Up @@ -361,10 +360,11 @@ describe('bundleExtension()', () => {
stdout,
stderr,
environment: 'production',
bundleOutputPath,
})

// Then
const filePaths = await glob(joinPath(themeExtension.outputPath, '/**/*'))
const filePaths = await glob(joinPath(bundleOutputPath, '/**/*'))
const hasFiles = filePaths
.map((filePath) => basename(filePath))
.some((filename) => ignoredFiles.includes(filename))
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/cli/services/extensions/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ export async function bundleThemeExtension(
options: ExtensionBuildOptions,
): Promise<void> {
options.stdout.write(`Bundling theme extension ${extension.localIdentifier}...`)
const outputDir = options.bundleOutputPath ?? extension.outputPath
const files = await themeExtensionFiles(extension)

await Promise.all(
files.map(function (filepath) {
const relativePathName = relativePath(extension.directory, filepath)
const outputFile = joinPath(extension.outputPath, relativePathName)
const outputFile = joinPath(outputDir, relativePathName)
if (filepath === outputFile) return
return copyFile(filepath, outputFile)
}),
Expand All @@ -90,6 +91,7 @@ export async function copyFilesForExtension(
ignoredPatterns: string[] = [],
): Promise<void> {
options.stdout.write(`Copying files for extension ${extension.localIdentifier}...`)
const outputDir = options.bundleOutputPath ?? extension.outputPath
const include = includePatterns.map((pattern) => joinPath('**', pattern))
const ignored = ignoredPatterns.map((pattern) => joinPath('**', pattern))
const files = await glob(include, {
Expand All @@ -101,7 +103,7 @@ export async function copyFilesForExtension(
await Promise.all(
files.map(function (filepath) {
const relativePathName = relativePath(extension.directory, filepath)
const outputFile = joinPath(extension.outputPath, relativePathName)
const outputFile = joinPath(outputDir, relativePathName)
if (filepath === outputFile) return
return copyFile(filepath, outputFile)
}),
Expand Down
Loading
Loading