Skip to content
Draft
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 @@ -164,7 +164,29 @@ describe('build', async () => {
const outputFilePath = joinPath(tmpDir, `dist/${extensionInstance.outputFileName}`)

// When
await extensionInstance.build(options)
await extensionInstance.build(options, 'build')

// Then
const outputFileContent = await readFile(outputFilePath)
expect(outputFileContent).toEqual('(()=>{})();')
})
})

test("'bundle' lifecycle still runs the build steps even when no 'bundle' group is declared", async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given — tax_calculation only declares a 'build' lifecycle group.
const extensionInstance = await testTaxCalculationExtension(tmpDir)
const options: ExtensionBuildOptions = {
stdout: new Writable({write: (_chunk, _enc, cb) => cb()}),
stderr: new Writable({write: (_chunk, _enc, cb) => cb()}),
app: testApp(),
environment: 'production',
}

const outputFilePath = joinPath(tmpDir, `dist/${extensionInstance.outputFileName}`)

// When — request the 'bundle' lifecycle. Composition still runs build steps even without a bundle group.
await extensionInstance.build(options, 'bundle')

// Then
const outputFileContent = await readFile(outputFilePath)
Expand Down
16 changes: 11 additions & 5 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {Identifiers} from '../app/identifiers.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {AppConfiguration} from '../app/app.js'
import {ApplicationURLs} from '../../services/dev/urls.js'
import {executeStep, BuildContext} from '../../services/build/client-steps.js'
import {executeStep, BuildContext, LifecyclePhase} from '../../services/build/client-steps.js'
import {ok} from '@shopify/cli-kit/node/result'
import {constantize, slugify} from '@shopify/cli-kit/common/string'
import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto'
Expand Down Expand Up @@ -114,7 +114,9 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi

get hasDeploySteps(): boolean {
return (
this.specification.clientSteps?.some((group) => group.lifecycle === 'deploy' && group.steps.length > 0) ?? false
this.specification.clientSteps?.some(
(group) => (group.lifecycle === 'build' || group.lifecycle === 'bundle') && group.steps.length > 0,
) ?? false
)
}

Expand Down Expand Up @@ -315,7 +317,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return Boolean(this.entrySourceFilePath.endsWith('.js') || this.entrySourceFilePath.endsWith('.ts'))
}

async build(options: ExtensionBuildOptions): Promise<void> {
async build(options: ExtensionBuildOptions, lifecycle: LifecyclePhase): Promise<void> {
const {clientSteps = []} = this.specification

const context: BuildContext = {
Expand All @@ -324,7 +326,11 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
stepResults: new Map(),
}

const steps = clientSteps.find((lifecycle) => lifecycle.lifecycle === 'deploy')?.steps ?? []
// Phases compose additively: 'bundle' runs build steps first, then bundle steps.
// Steps within each group preserve declaration order.
const buildSteps = clientSteps.find((group) => group.lifecycle === 'build')?.steps ?? []
const bundleSteps = clientSteps.find((group) => group.lifecycle === 'bundle')?.steps ?? []
const steps = lifecycle === 'build' ? buildSteps : [...buildSteps, ...bundleSteps]

for (const step of steps) {
// eslint-disable-next-line no-await-in-loop
Expand All @@ -335,7 +341,7 @@ 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)
await this.build(options, 'bundle')

const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
await this.keepBuiltSourcemapsLocally(bundleInputPath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('allLocalSpecs', () => {

const testClientSteps: ClientSteps = [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [
{
id: 'bundle-ui',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const adminSpecificationSpec = createExtensionSpecification<AdminConfigType>({
},
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'bundle',
steps: [
{
id: 'hosted_app_copy_files',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('admin_link', async () => {
const extension = await getTestAdminLink(tmpDir)
const clientSteps = extension.specification.clientSteps!
expect(clientSteps).toHaveLength(1)
expect(clientSteps[0]!.lifecycle).toBe('deploy')
expect(clientSteps[0]!.lifecycle).toBe('bundle')

const steps = clientSteps[0]!.steps
expect(steps).toHaveLength(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const adminLinkSpec = createContractBasedModuleSpecification({
experience: 'extension',
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'bundle',
steps: [
{
id: 'include-admin-link-assets',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({
experience: 'extension',
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'bundle',
steps: [
{
id: 'copy-files',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({
`dist/${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const checkoutSpec = createExtensionSpecification({
getOutputRelativePath: (extension: ExtensionInstance<CheckoutConfigType>) => `dist/${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const flowTemplateSpec = createExtensionSpecification({
appModuleFeatures: (_) => ['ui_preview'],
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'bundle',
steps: [
{
id: 'copy-files',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const functionSpec = createExtensionSpecification({
},
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'build-function', name: 'Build Function', type: 'build_function', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const orderAttributionConfigSpec = createContractBasedModuleSpecification({
experience: 'extension',
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'bundle',
steps: [
{
id: 'copy-files',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const posUISpec = createExtensionSpecification({
getOutputRelativePath: (extension: ExtensionInstance<PosUIConfigType>) => `dist/${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const productSubscriptionSpec = createExtensionSpecification({
getOutputRelativePath: (extension: ExtensionInstance<ProductSubscriptionConfigType>) => `dist/${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const spec = createExtensionSpecification({
joinPath('dist', `${extension.handle}.js`),
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'create-tax-stub', name: 'Create Tax Stub', type: 'create_tax_stub', config: {}}],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const themeSpec = createExtensionSpecification({
graphQLType: 'theme_app_extension',
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [
{id: 'build-theme', name: 'Build Theme Extension', type: 'build_theme', config: {}},
{id: 'bundle-theme', name: 'Bundle Theme Extension', type: 'bundle_theme', config: {}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,24 @@ const uiExtensionSpec = createExtensionSpecification({
getOutputRelativePath: (extension: ExtensionInstance<UIExtensionConfigType>) => `${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [
{
id: 'bundle-ui',
name: 'Bundle UI Extension',
type: 'bundle_ui',
config: {generatesAssetsManifest: true, bundleFolder: 'dist/'},
config: {bundleFolder: 'dist/'},
},
],
},
{
lifecycle: 'bundle',
steps: [
{
id: 'generate-ui-assets-manifest',
name: 'Generate UI Assets Manifest',
type: 'generate_ui_assets_manifest',
config: {bundleFolder: 'dist/'},
},
{
id: 'include-ui-extension-assets',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const webPixelSpec = createExtensionSpecification({
getOutputRelativePath: (extension: ExtensionInstance<WebPixelConfigType>) => `dist/${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
lifecycle: 'build',
steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}],
},
],
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async function build(options: BuildOptions) {
return {
prefix: ext.localIdentifier,
action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => {
await ext.build({stdout, stderr, signal, app: options.app, environment: 'production'})
await ext.build({stdout, stderr, signal, app: options.app, environment: 'production'}, 'build')
},
}
}),
Expand Down
32 changes: 28 additions & 4 deletions packages/app/src/cli/services/build/client-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ interface IncludeAssetsStep extends BaseStep {
export interface BundleUIStep extends BaseStep {
readonly type: 'bundle_ui'
readonly config?: {
readonly generatesAssetsManifest?: boolean
readonly bundleFolder?: string
}
}

/** Step with typed config specific to generate_ui_assets_manifest. */
export interface GenerateUIAssetsManifestStep extends BaseStep {
readonly type: 'generate_ui_assets_manifest'
readonly config?: {
readonly bundleFolder?: string
}
}
Expand All @@ -43,14 +50,31 @@ interface NoConfigStep extends BaseStep {
* This is a discriminated union on `type`: each step type carries its own
* typed `config`, so TypeScript catches config typos at compile time.
*/
export type LifecycleStep = IncludeAssetsStep | BundleUIStep | NoConfigStep
export type LifecycleStep = IncludeAssetsStep | BundleUIStep | GenerateUIAssetsManifestStep | NoConfigStep

/**
* Lifecycle phases supported by the client-step pipeline.
*
* Phases compose additively. A spec lists only the steps that are *unique* to a
* phase; the runtime combines them per command:
*
* - `'build'` — local build (e.g. `shopify app build`). Steps required to
* produce the on-disk artifact.
* - `'bundle'` — extra steps needed when bundling for upload (driven by `dev`
* and `deploy`). Run *after* the build steps; never on their own.
*
* The `build` command runs `'build'` steps. The `dev`/`deploy` pipeline runs
* `'build'` then `'bundle'`. A spec only declares a `'bundle'` group when the
* bundle phase needs to do something the local build doesn't.
*/
export type LifecyclePhase = 'build' | 'bundle'

/**
* A group of steps scoped to a specific lifecycle phase.
* Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy').
* Allows executing only the steps relevant to a given lifecycle.
*/
interface ClientLifecycleGroup {
readonly lifecycle: 'deploy'
readonly lifecycle: LifecyclePhase
readonly steps: ReadonlyArray<LifecycleStep>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('executeBundleUIStep', () => {
id: 'bundle-ui',
name: 'Bundle UI Extension',
type: 'bundle_ui',
config: {generatesAssetsManifest: false},
config: {},
}

test('copies when local and bundle output directories differ', async () => {
Expand Down
48 changes: 2 additions & 46 deletions packages/app/src/cli/services/build/steps/bundle-ui-step.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import {createOrUpdateManifestFile} from './include-assets/generate-manifest.js'
import {buildUIExtension} from '../extension.js'
import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js'
import {copyFile} from '@shopify/cli-kit/node/fs'
import {dirname, joinPath, resolvePath} from '@shopify/cli-kit/node/path'
import type {BundleUIStep, BuildContext} from '../client-steps.js'

interface ExtensionPointWithBuildManifest {
target: string
build_manifest: BuildManifest
}

/**
* Executes a bundle_ui build step.
*
* Bundles the UI extension using esbuild into the extension's local directory
* and copies the output to the bundle. When `generatesAssetsManifest` is true,
* writes built asset entries (from build_manifest) into manifest.json so
* downstream steps can merge on top.
* and copies the output to the bundle. Manifest emission lives in the separate
* `generate_ui_assets_manifest` step so it only runs in lifecycles that need it.
*/
export async function executeBundleUIStep(step: BundleUIStep, context: BuildContext): Promise<void> {
const config = context.extension.configuration
context.options.buildDirectory = step.config?.bundleFolder ?? undefined
const localOutputPath = await buildUIExtension(context.extension, context.options)
const localOutputDir = dirname(localOutputPath)
Expand All @@ -29,39 +20,4 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont
if (resolvePath(localOutputDir) !== resolvePath(bundleOutputDir)) {
await copyFile(localOutputDir, bundleOutputDir)
}

if (!step.config?.generatesAssetsManifest) return

if (!Array.isArray(config.extension_points)) return

const pointsWithManifest = config.extension_points.filter(
(ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest,
)

const entries = extractBuiltAssetEntries(pointsWithManifest, step.config?.bundleFolder)
if (Object.keys(entries).length > 0) {
await createOrUpdateManifestFile(context, entries)
}
}

/**
* Extracts built asset filepaths from `build_manifest` on each extension point,
* grouped by target. Returns a map of target → `{assetName: filepath}`.
*/
function extractBuiltAssetEntries(
extensionPoints: {target: string; build_manifest: BuildManifest}[],
bundleFolder?: string,
): {
[target: string]: {[assetName: string]: string}
} {
const entries: {[target: string]: {[assetName: string]: string}} = {}
for (const {target, build_manifest: buildManifest} of extensionPoints) {
if (!buildManifest?.assets) continue
const assets: {[name: string]: string} = {}
for (const [name, asset] of Object.entries(buildManifest.assets)) {
if (asset?.filepath) assets[name] = bundleFolder ? joinPath(bundleFolder, asset.filepath) : asset.filepath
}
if (Object.keys(assets).length > 0) entries[target] = assets
}
return entries
}
Loading
Loading