Skip to content

Commit ba0dc62

Browse files
isaacroldanclaude
andcommitted
fix: stop base64-encoding dist/index.wasm during function builds
CLI 3.93.0 introduced a regression where dist/index.wasm gets base64-encoded text instead of raw binary after `shopify app build`. This breaks vitest for Rust function extensions. Root cause: extension.outputPath was mutated at runtime by buildFunctionExtension, buildForBundle, and copyIntoBundle. The value you got depended on when you read it. Fix: - Make outputPath effectively readonly (only set in the constructor) - Add bundleOutputPath to ExtensionBuildOptions as an explicit override - buildForBundle/copyIntoBundle pass bundleOutputPath through options instead of mutating this.outputPath - buildFunctionExtension uses local buildOutputPath variable, copies raw binary to extension.outputPath, and only base64-encodes when bundleOutputPath is set - Thread explicit outputPath through the JS build chain (buildJSFunction → JavyBuilder.compile → runJavy) so runJavy no longer reads fun.outputPath Co-Authored-By: Isaac Roldán <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 7a735be commit ba0dc62

6 files changed

Lines changed: 124 additions & 66 deletions

File tree

packages/app/src/cli/models/extensions/extension-instance.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,11 @@ describe('build', async () => {
224224
await extension.copyIntoBundle(options, bundleDirectory, 'uuid')
225225

226226
// Then
227-
const outputTomlPath = joinPath(extension.outputPath, 'shopify.extension.toml')
227+
const bundleOutputPath = extension.getOutputPathForDirectory(bundleDirectory, 'uuid')
228+
const outputTomlPath = joinPath(bundleOutputPath, 'shopify.extension.toml')
228229
expect(fileExistsSync(outputTomlPath)).toBe(false)
229230

230-
const outputProductPath = joinPath(extension.outputPath, 'blocks', 'product.liquid')
231+
const outputProductPath = joinPath(bundleOutputPath, 'blocks', 'product.liquid')
231232
expect(fileExistsSync(outputProductPath)).toBe(true)
232233
})
233234
})

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {constantize, slugify} from '@shopify/cli-kit/common/string'
1414
import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto'
1515
import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn'
1616
import {joinPath, normalizePath, resolvePath, relativePath, basename} from '@shopify/cli-kit/node/path'
17-
import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
17+
import {fileExists, moveFile, glob, copyFile, globSync, touchFile} from '@shopify/cli-kit/node/fs'
1818
import {getPathValue} from '@shopify/cli-kit/common/object'
1919
import {outputDebug} from '@shopify/cli-kit/node/output'
2020
import {
@@ -337,29 +337,26 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
337337
}
338338

339339
async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
340-
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
341-
await this.build(options)
340+
const bundleOutputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
341+
await this.build({...options, bundleOutputPath})
342342

343343
const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
344344
await this.keepBuiltSourcemapsLocally(bundleInputPath)
345345
}
346346

347347
async copyIntoBundle(options: ExtensionBuildOptions, bundleDirectory: string, extensionUuid?: string) {
348-
const defaultOutputPath = this.outputPath
349-
350-
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)
351-
352-
const buildMode = this.specification.buildConfig.mode
348+
const bundleOutputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)
353349

354350
if (this.isThemeExtension) {
355-
await bundleThemeExtension(this, options)
356-
} else if (buildMode !== 'none') {
357-
outputDebug(`Will copy pre-built file from ${defaultOutputPath} to ${this.outputPath}`)
358-
if (await fileExists(defaultOutputPath)) {
359-
await copyFile(defaultOutputPath, this.outputPath)
360-
361-
if (buildMode === 'function') {
362-
await bundleFunctionExtension(this.outputPath, this.outputPath)
351+
await bundleThemeExtension(this, {...options, bundleOutputPath})
352+
} else if (this.specification.buildConfig.mode !== 'none') {
353+
outputDebug(`Will copy pre-built file from ${this.outputPath} to ${bundleOutputPath}`)
354+
if (await fileExists(this.outputPath)) {
355+
await touchFile(bundleOutputPath)
356+
await copyFile(this.outputPath, bundleOutputPath)
357+
358+
if (this.isFunctionExtension) {
359+
await bundleFunctionExtension(bundleOutputPath, bundleOutputPath)
363360
}
364361
}
365362
}

packages/app/src/cli/services/build/extension.test.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'
77
import {exec} from '@shopify/cli-kit/node/system'
88
import lockfile from 'proper-lockfile'
99
import {AbortError} from '@shopify/cli-kit/node/error'
10-
import {fileExistsSync, touchFile, writeFile} from '@shopify/cli-kit/node/fs'
10+
import {copyFile, fileExistsSync, touchFile, writeFile} from '@shopify/cli-kit/node/fs'
1111
import {joinPath} from '@shopify/cli-kit/node/path'
1212

1313
vi.mock('@shopify/cli-kit/node/system')
@@ -106,13 +106,17 @@ describe('buildFunctionExtension', () => {
106106
).resolves.toBeUndefined()
107107

108108
// Then
109-
expect(buildJSFunction).toHaveBeenCalledWith(extension, {
110-
stdout,
111-
stderr,
112-
signal,
113-
app,
114-
environment: 'production',
115-
})
109+
expect(buildJSFunction).toHaveBeenCalledWith(
110+
extension,
111+
{
112+
stdout,
113+
stderr,
114+
signal,
115+
app,
116+
environment: 'production',
117+
},
118+
joinPath(extension.directory, 'dist/index.wasm'),
119+
)
116120
expect(releaseLock).toHaveBeenCalled()
117121
})
118122

@@ -243,13 +247,17 @@ describe('buildFunctionExtension', () => {
243247
).resolves.toBeUndefined()
244248

245249
// Then
246-
expect(buildJSFunction).toHaveBeenCalledWith(extension, {
247-
stdout,
248-
stderr,
249-
signal,
250-
app,
251-
environment: 'production',
252-
})
250+
expect(buildJSFunction).toHaveBeenCalledWith(
251+
extension,
252+
{
253+
stdout,
254+
stderr,
255+
signal,
256+
app,
257+
environment: 'production',
258+
},
259+
joinPath(extension.directory, 'dist', 'index.wasm'),
260+
)
253261
expect(releaseLock).toHaveBeenCalled()
254262
// wasm_opt should not be called when build config is undefined
255263
expect(runWasmOpt).not.toHaveBeenCalled()
@@ -418,7 +426,7 @@ describe('buildFunctionExtension', () => {
418426
expect(runWasmOpt).toHaveBeenCalled()
419427
})
420428

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

437445
// Then
438-
expect(fileExistsSync).toHaveBeenCalledWith(joinPath(extension.directory, 'dist/custom.wasm'))
439-
expect(touchFile).not.toHaveBeenCalled()
446+
const buildOutputPath = joinPath(extension.directory, 'dist/custom.wasm')
447+
const canonicalOutputPath = joinPath(extension.directory, 'dist', 'index.wasm')
448+
expect(fileExistsSync).toHaveBeenCalledWith(buildOutputPath)
449+
expect(touchFile).toHaveBeenCalledWith(canonicalOutputPath)
450+
expect(copyFile).toHaveBeenCalledWith(buildOutputPath, canonicalOutputPath)
451+
// Must NOT base64-encode during build — only raw binary copy
440452
expect(writeFile).not.toHaveBeenCalled()
441453
})
454+
455+
test('does not mutate extension.outputPath', async () => {
456+
// Given
457+
extension.configuration.build!.path = 'target/wasm32-wasi/release/func.wasm'
458+
vi.mocked(fileExistsSync).mockReturnValue(true)
459+
const originalOutputPath = extension.outputPath
460+
461+
// When
462+
await buildFunctionExtension(extension, {
463+
stdout,
464+
stderr,
465+
signal,
466+
app,
467+
environment: 'production',
468+
})
469+
470+
// Then
471+
expect(extension.outputPath).toBe(originalOutputPath)
472+
})
442473
})

packages/app/src/cli/services/build/extension.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
1010
import lockfile from 'proper-lockfile'
1111
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
1212
import {outputDebug} from '@shopify/cli-kit/node/output'
13-
import {readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
13+
import {copyFile, readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
1414
import {Writable} from 'stream'
1515

1616
export interface ExtensionBuildOptions {
@@ -53,6 +53,14 @@ export interface ExtensionBuildOptions {
5353
* The URL where the app is running.
5454
*/
5555
appURL?: string
56+
57+
/**
58+
* When building for a deploy or dev bundle, this is the output path inside the
59+
* bundle directory. When set, build functions write their final artifact here
60+
* instead of extension.outputPath. This avoids mutating extension.outputPath at
61+
* runtime.
62+
*/
63+
bundleOutputPath?: string
5664
}
5765

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

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

7180
try {
7281
await bundleExtension({
7382
minify: true,
74-
outputPath: extension.outputPath,
83+
outputPath,
7584
stdin: {
7685
contents: main,
7786
resolveDir: extension.directory,
@@ -88,7 +97,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
8897
assets.map(async (asset) => {
8998
await bundleExtension({
9099
minify: true,
91-
outputPath: joinPath(dirname(extension.outputPath), asset.outputFileName),
100+
outputPath: joinPath(dirname(outputPath), asset.outputFileName),
92101
stdin: {
93102
contents: asset.content,
94103
resolveDir: extension.directory,
@@ -111,7 +120,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
111120

112121
await extension.buildValidation()
113122

114-
const sizeInfo = await formatBundleSize(extension.outputPath)
123+
const sizeInfo = await formatBundleSize(outputPath)
115124
options.stdout.write(`${extension.localIdentifier} successfully built${sizeInfo}`)
116125
}
117126

@@ -140,33 +149,37 @@ export async function buildFunctionExtension(
140149
}
141150

142151
try {
143-
const bundlePath = extension.outputPath
144152
const relativeBuildPath =
145153
(extension as ExtensionInstance<FunctionConfigType>).configuration.build?.path ?? extension.outputRelativePath
146-
147-
extension.outputPath = joinPath(extension.directory, relativeBuildPath)
154+
const buildOutputPath = joinPath(extension.directory, relativeBuildPath)
148155

149156
if (extension.isJavaScript) {
150-
await runCommandOrBuildJSFunction(extension, options)
157+
await runCommandOrBuildJSFunction(extension, options, buildOutputPath)
151158
} else {
152159
await buildOtherFunction(extension, options)
153160
}
154161

155162
const wasmOpt = (extension as ExtensionInstance<FunctionConfigType>).configuration.build?.wasm_opt
156-
if (fileExistsSync(extension.outputPath) && wasmOpt) {
157-
await runWasmOpt(extension.outputPath)
163+
if (fileExistsSync(buildOutputPath) && wasmOpt) {
164+
await runWasmOpt(buildOutputPath)
158165
}
159166

160-
if (fileExistsSync(extension.outputPath)) {
161-
await runTrampoline(extension.outputPath)
167+
if (fileExistsSync(buildOutputPath)) {
168+
await runTrampoline(buildOutputPath)
162169
}
163170

164-
if (
165-
fileExistsSync(extension.outputPath) &&
166-
bundlePath !== extension.outputPath &&
167-
dirname(bundlePath) !== dirname(extension.outputPath)
168-
) {
169-
await bundleFunctionExtension(extension.outputPath, bundlePath)
171+
// Copy raw binary to the canonical output path (dist/index.wasm) if build wrote elsewhere
172+
if (fileExistsSync(buildOutputPath) && extension.outputPath !== buildOutputPath) {
173+
outputDebug(`Copying WASM from ${buildOutputPath} to ${extension.outputPath}`)
174+
await touchFile(extension.outputPath)
175+
await copyFile(buildOutputPath, extension.outputPath)
176+
}
177+
178+
// When building for a bundle, base64-encode the wasm into the bundle directory
179+
if (options.bundleOutputPath && fileExistsSync(extension.outputPath)) {
180+
await touchFile(options.bundleOutputPath)
181+
await copyFile(extension.outputPath, options.bundleOutputPath)
182+
await bundleFunctionExtension(options.bundleOutputPath, options.bundleOutputPath)
170183
}
171184
// eslint-disable-next-line @typescript-eslint/no-explicit-any
172185
} catch (error: any) {
@@ -195,14 +208,18 @@ export async function bundleFunctionExtension(wasmPath: string, bundlePath: stri
195208
await writeFile(bundlePath, base64Contents)
196209
}
197210

198-
async function runCommandOrBuildJSFunction(extension: ExtensionInstance, options: BuildFunctionExtensionOptions) {
211+
async function runCommandOrBuildJSFunction(
212+
extension: ExtensionInstance,
213+
options: BuildFunctionExtensionOptions,
214+
buildOutputPath: string,
215+
) {
199216
if (extension.buildCommand) {
200217
if (extension.typegenCommand) {
201218
await buildGraphqlTypes(extension, options)
202219
}
203220
return runCommand(extension.buildCommand, extension, options)
204221
} else {
205-
return buildJSFunction(extension as ExtensionInstance<FunctionConfigType>, options)
222+
return buildJSFunction(extension as ExtensionInstance<FunctionConfigType>, options, buildOutputPath)
206223
}
207224
}
208225

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,13 @@ export async function bundleThemeExtension(
7171
options: ExtensionBuildOptions,
7272
): Promise<void> {
7373
options.stdout.write(`Bundling theme extension ${extension.localIdentifier}...`)
74+
const outputDir = options.bundleOutputPath ?? extension.outputPath
7475
const files = await themeExtensionFiles(extension)
7576

7677
await Promise.all(
7778
files.map(function (filepath) {
7879
const relativePathName = relativePath(extension.directory, filepath)
79-
const outputFile = joinPath(extension.outputPath, relativePathName)
80+
const outputFile = joinPath(outputDir, relativePathName)
8081
if (filepath === outputFile) return
8182
return copyFile(filepath, outputFile)
8283
}),
@@ -90,6 +91,7 @@ export async function copyFilesForExtension(
9091
ignoredPatterns: string[] = [],
9192
): Promise<void> {
9293
options.stdout.write(`Copying files for extension ${extension.localIdentifier}...`)
94+
const outputDir = options.bundleOutputPath ?? extension.outputPath
9395
const include = includePatterns.map((pattern) => joinPath('**', pattern))
9496
const ignored = ignoredPatterns.map((pattern) => joinPath('**', pattern))
9597
const files = await glob(include, {
@@ -101,7 +103,7 @@ export async function copyFilesForExtension(
101103
await Promise.all(
102104
files.map(function (filepath) {
103105
const relativePathName = relativePath(extension.directory, filepath)
104-
const outputFile = joinPath(extension.outputPath, relativePathName)
106+
const outputFile = joinPath(outputDir, relativePathName)
105107
if (filepath === outputFile) return
106108
return copyFile(filepath, outputFile)
107109
}),

0 commit comments

Comments
 (0)