diff --git a/.changeset/nine-rabbits-win.md b/.changeset/nine-rabbits-win.md new file mode 100644 index 00000000000..e6ea7698b43 --- /dev/null +++ b/.changeset/nine-rabbits-win.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Emit esbuild metafiles for ui extensions diff --git a/packages/app/src/cli/services/bundle.test.ts b/packages/app/src/cli/services/bundle.test.ts index 68b8a5f09da..0adf8cbe87a 100644 --- a/packages/app/src/cli/services/bundle.test.ts +++ b/packages/app/src/cli/services/bundle.test.ts @@ -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' @@ -53,7 +53,7 @@ describe('compressBundle', () => { expect(zip).toHaveBeenCalledWith({ inputDirectory: inputDir, outputZipPath: outputZip, - matchFilePattern: ['**/*', '!**/*.js.map'], + matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS], }) expect(brotliCompress).not.toHaveBeenCalled() }) @@ -74,7 +74,7 @@ describe('compressBundle', () => { // Then expect(zip).toHaveBeenCalledWith( expect.objectContaining({ - matchFilePattern: ['**/*', '!**/*.js.map'], + matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS], }), ) }) @@ -95,7 +95,7 @@ describe('compressBundle', () => { expect(brotliCompress).toHaveBeenCalledWith({ inputDirectory: inputDir, outputPath: outputBr, - matchFilePattern: ['**/*', '!**/*.js.map'], + matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS], }) expect(zip).not.toHaveBeenCalled() }) @@ -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'], }), ) }) diff --git a/packages/app/src/cli/services/bundle.ts b/packages/app/src/cli/services/bundle.ts index c3decdbe883..f849da2a77d 100644 --- a/packages/app/src/cli/services/bundle.ts +++ b/packages/app/src/cli/services/bundle.ts @@ -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 { diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index 8c87de227fb..5a24c0916f0 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -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' @@ -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) diff --git a/packages/app/src/cli/services/extensions/bundle.test.ts b/packages/app/src/cli/services/extensions/bundle.test.ts index 10c62e4be56..c28df295249 100644 --- a/packages/app/src/cli/services/extensions/bundle.test.ts +++ b/packages/app/src/cli/services/extensions/bundle.test.ts @@ -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 { @@ -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() diff --git a/packages/app/src/cli/services/extensions/bundle.ts b/packages/app/src/cli/services/extensions/bundle.ts index 173d4a63550..86c40274191 100644 --- a/packages/app/src/cli/services/extensions/bundle.ts +++ b/packages/app/src/cli/services/extensions/bundle.ts @@ -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' @@ -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() } @@ -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') { + esbuildOptions.metafile = true + } return esbuildOptions } diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index 1b064805c9c..23ba9ad1ac4 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -7,6 +7,7 @@ import { resolve, basename as basenamePathe, extname as extnamePathe, + parse, isAbsolute, } from 'pathe' import {fileURLToPath} from 'url' @@ -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