Skip to content
Merged
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 @@ -83,12 +83,17 @@ const uiExtensionSpec = createExtensionSpecification({
identifier: 'ui_extension',
dependency,
schema: UIExtensionSchema,
getOutputRelativePath: (extension: ExtensionInstance<UIExtensionConfigType>) => `dist/${extension.handle}.js`,
getOutputRelativePath: (extension: ExtensionInstance<UIExtensionConfigType>) => `${extension.handle}.js`,
clientSteps: [
{
lifecycle: 'deploy',
steps: [
{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {generatesAssetsManifest: true}},
{
id: 'bundle-ui',
name: 'Bundle UI Extension',
type: 'bundle_ui',
config: {generatesAssetsManifest: true, bundleFolder: 'dist/'},
},
{
id: 'include-ui-extension-assets',
name: 'Include UI Extension Assets',
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/services/build/client-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BundleUIStep extends BaseStep {
readonly type: 'bundle_ui'
readonly config?: {
readonly generatesAssetsManifest?: boolean
readonly bundleFolder?: string
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/cli/services/build/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
env.APP_URL = options.appURL
}

const buildDirectory = options.buildDirectory ?? ''

// Always build into the extension's local directory (e.g. ext/dist/handle.js)
const localOutputPath = joinPath(extension.directory, extension.outputRelativePath)
const localOutputPath = joinPath(extension.directory, buildDirectory, extension.outputRelativePath)
Comment on lines +70 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this buildDirectory includes the value of bundleFolder? :thinking_face:
I'm missing how this is connected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, it is set here.


const {main, assets} = extension.getBundleExtensionStdinContent()

Expand Down
16 changes: 11 additions & 5 deletions packages/app/src/cli/services/build/steps/bundle-ui-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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} from '@shopify/cli-kit/node/path'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import type {BundleUIStep, BuildContext} from '../client-steps.js'

interface ExtensionPointWithBuildManifest {
Expand All @@ -20,10 +20,13 @@ interface ExtensionPointWithBuildManifest {
*/
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)
// When invoked outside a bundle directory (e.g. `shopify app build`), localOutputPath and outputPath collapse onto the same directory; fs-extra rejects same-path copies.
const localOutputDir = dirname(localOutputPath)
const bundleOutputDir = dirname(context.extension.outputPath)
const bundleOutputDir = step.config?.bundleFolder
? joinPath(dirname(context.extension.outputPath), step.config.bundleFolder)
: dirname(context.extension.outputPath)
if (localOutputDir !== bundleOutputDir) {
await copyFile(localOutputDir, bundleOutputDir)
}
Expand All @@ -36,7 +39,7 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont
(ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest,
)

const entries = extractBuiltAssetEntries(pointsWithManifest)
const entries = extractBuiltAssetEntries(pointsWithManifest, step.config?.bundleFolder)
if (Object.keys(entries).length > 0) {
await createOrUpdateManifestFile(context, entries)
}
Expand All @@ -46,15 +49,18 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont
* 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}[]): {
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] = asset.filepath
if (asset?.filepath) assets[name] = bundleFolder ? joinPath(bundleFolder, asset.filepath) : asset.filepath
}
if (Object.keys(assets).length > 0) entries[target] = assets
}
Expand Down
69 changes: 55 additions & 14 deletions packages/app/src/cli/services/dev/extension/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,8 @@ describe('getUIExtensionPayload', () => {
})
})

test('maps main and should_render from build_manifest', async () => {
test('maps main and should_render paths from manifest.json', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const buildManifest = {
assets: {
main: {module: './src/ExtensionPointA.js', filepath: 'test-ui-extension.js'},
should_render: {module: './src/ShouldRender.js', filepath: 'test-ui-extension-conditions.js'},
},
}

const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
Expand All @@ -224,18 +217,12 @@ describe('getUIExtensionPayload', () => {
{
target: 'CUSTOM_EXTENSION_POINT',
module: './src/ExtensionPointA.js',
build_manifest: buildManifest,
},
],
},
devUUID: 'devUUID',
})

// Create source files so lastUpdated resolves
await mkdir(joinPath(tmpDir, 'src'))
await writeFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js'), '// main')
await writeFile(joinPath(tmpDir, 'src', 'ShouldRender.js'), '// should render')

await setupBuildOutput(
uiExtension,
tmpDir,
Expand Down Expand Up @@ -268,6 +255,60 @@ describe('getUIExtensionPayload', () => {
})
})

test('maps main and should_render with bundleFolder prefix from manifest.json', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
name: 'test-ui-extension',
type: 'ui_extension',
extension_points: [
{
target: 'CUSTOM_EXTENSION_POINT',
module: './src/ExtensionPointA.js',
},
],
},
devUUID: 'devUUID',
})

await setupBuildOutput(
uiExtension,
tmpDir,
{
CUSTOM_EXTENSION_POINT: {
main: 'dist/test-ui-extension.js',
should_render: 'dist/test-ui-extension-conditions.js',
},
},
{},
)

const got = await getUIExtensionPayload(uiExtension, tmpDir, {
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
})

expect(got.extensionPoints).toMatchObject([
{
target: 'CUSTOM_EXTENSION_POINT',
assets: {
main: {
name: 'main',
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension.js',
lastUpdated: expect.any(Number),
},
should_render: {
name: 'should_render',
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension-conditions.js',
lastUpdated: expect.any(Number),
},
},
},
])
})
})

test('maps intents from manifest.json to asset payloads', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
Expand Down
42 changes: 35 additions & 7 deletions packages/app/src/cli/services/dev/extension/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface AssetMapperContext {
extensionPoint: DevNewExtensionPointSchema
url: string
extension: ExtensionInstance
manifestValue?: unknown
}

export async function getUIExtensionPayload(
Expand Down Expand Up @@ -47,11 +48,14 @@ export async function getUIExtensionPayload(

const defaultConfig = {
assets: {
main: {
name: 'main',
url: `${url}/assets/${extension.outputFileName}`,
lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0,
},
main:
isNewExtensionPointsSchema(extensionPoints) && extensionPoints[0]?.assets?.main
? extensionPoints[0].assets.main
: {
name: 'main',
url: `${url}/assets/${extension.outputFileName}`,
lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0,
},
},
supportedFeatures: {
runsOffline: extension.configuration.supported_features?.runs_offline ?? false,
Expand Down Expand Up @@ -134,10 +138,11 @@ async function getExtensionPoints(extension: ExtensionInstance, url: string, bui
return payload
}

return {
const payloadWithAssets = {
...payload,
...(await mapManifestAssetsToPayload(manifestEntry, extensionPoint, url, extension)),
}
return payloadWithAssets
}),
)
}
Expand Down Expand Up @@ -215,12 +220,29 @@ async function intentsAssetMapper({

type AssetMapper = (context: AssetMapperContext) => Promise<Partial<DevNewExtensionPointSchema>>

/**
* Mapper for compiled built assets (main, should_render).
* Reads the filepath directly from manifest.json so the bundleFolder prefix is preserved.
*/
async function builtAssetMapper({
identifier,
manifestValue,
url,
extension,
}: AssetMapperContext): Promise<Partial<DevNewExtensionPointSchema>> {
if (typeof manifestValue !== 'string') return {}
const payload = await getAssetPayload(identifier, manifestValue, url, extension)
return {assets: {[payload.name]: payload}}
}

/**
* Asset mappers registry - defines how each asset type should be handled.
* Assets not in this registry use the defaultAssetMapper.
*/
const ASSET_MAPPERS: {[key: string]: AssetMapper | undefined} = {
intents: intentsAssetMapper,
main: builtAssetMapper,
should_render: builtAssetMapper,
}

/**
Expand All @@ -236,7 +258,13 @@ async function mapManifestAssetsToPayload(
): Promise<Partial<DevNewExtensionPointSchema>> {
const mappingResults = await Promise.all(
Object.keys(manifestEntry).map(async (identifier) => {
const context: AssetMapperContext = {identifier, extensionPoint, url, extension}
const context: AssetMapperContext = {
identifier,
extensionPoint,
url,
extension,
manifestValue: manifestEntry[identifier],
}
return ASSET_MAPPERS[identifier]?.(context) ?? defaultAssetMapper(context)
}),
)
Expand Down
Loading