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 @@ -14,7 +14,7 @@ import {
placeholderAppConfiguration,
} from '../app/app.test-data.js'
import {FunctionConfigType} from '../extensions/specifications/function.js'
import {ExtensionBuildOptions} from '../../services/build/extension.js'
import {ExtensionBuildOptions, buildUIExtension} from '../../services/build/extension.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {joinPath} from '@shopify/cli-kit/node/path'
import {describe, expect, test, vi} from 'vitest'
Expand All @@ -27,6 +27,15 @@ import {Writable} from 'stream'
const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient()

vi.mock('@shopify/cli-kit/node/import-extractor')
vi.mock('../../services/build/extension.js', async () => {
const actual = await vi.importActual('../../services/build/extension.js')
return {
...actual,
buildUIExtension: vi.fn(),
buildThemeExtension: vi.fn(),
buildFunctionExtension: vi.fn(),
}
})

function functionConfiguration(): FunctionConfigType {
return {
Expand Down Expand Up @@ -156,6 +165,33 @@ describe('build', async () => {
})
})

test('calls copyStaticAssets after buildUIExtension when building UI extensions', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const extensionInstance = await testUIExtension({
type: 'ui_extension',
directory: tmpDir,
})

const copyStaticAssetsSpy = vi.spyOn(extensionInstance, 'copyStaticAssets').mockResolvedValue()
vi.mocked(buildUIExtension).mockResolvedValue()

const options: ExtensionBuildOptions = {
stdout: new Writable({write: vi.fn()}),
stderr: new Writable({write: vi.fn()}),
app: testApp(),
environment: 'production',
}

// When
await extensionInstance.build(options)

// Then
expect(buildUIExtension).toHaveBeenCalledWith(extensionInstance, options)
expect(copyStaticAssetsSpy).toHaveBeenCalledOnce()
})
})

test('does not copy shopify.extension.toml file when bundling theme extensions', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
Expand Down
14 changes: 13 additions & 1 deletion packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,9 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
case 'function':
return buildFunctionExtension(this, options)
case 'ui':
return buildUIExtension(this, options)
await buildUIExtension(this, options)
// Copy static assets after build completes
return this.copyStaticAssets()
case 'tax_calculation':
await touchFile(this.outputPath)
await writeFile(this.outputPath, '(()=>{})();')
Expand Down Expand Up @@ -492,6 +494,16 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return [...new Set(watchedFiles.map((file) => normalizePath(file)))]
}

/**
* Copy static assets from the extension directory to the output path
* Used by both dev and deploy builds
*/
async copyStaticAssets(outputPath?: string) {
if (this.specification.copyStaticAssets) {
return this.specification.copyStaticAssets(this.configuration, this.directory, outputPath ?? this.outputPath)
}
}

/**
* Rescans imports for this extension and updates the cached import paths
* Returns true if the imports changed
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/models/extensions/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const NewExtensionPointSchema = zod.object({
target: zod.string(),
module: zod.string(),
should_render: ShouldRenderSchema.optional(),
tools: zod.string().optional(),
metafields: zod.array(MetafieldSchema).optional(),
default_placement: zod.string().optional(),
urls: zod
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type UidStrategy = 'single' | 'dynamic' | 'uuid'
export enum AssetIdentifier {
ShouldRender = 'should_render',
Main = 'main',
Tools = 'tools',
}

export interface Asset {
Expand Down Expand Up @@ -118,6 +119,11 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
extension: ExtensionInstance<TConfiguration>,
typeDefinitionsByFile: Map<string, Set<string>>,
) => Promise<void>

/**
* Copy static assets from the extension directory to the output path
*/
copyStaticAssets?: (configuration: TConfiguration, directory: string, outputPath: string) => Promise<void>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {loadLocalesConfig} from '../../../utilities/extensions/locales-configura
import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js'
import {ExtensionInstance} from '../extension-instance.js'
import {err, ok, Result} from '@shopify/cli-kit/node/result'
import {fileExists} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {copyFile, fileExists} from '@shopify/cli-kit/node/fs'
import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {zod} from '@shopify/cli-kit/node/schema'

Expand All @@ -27,6 +27,7 @@ export interface BuildManifest {
[key in AssetIdentifier]?: {
filepath: string
module?: string
static?: boolean
}
}
}
Expand Down Expand Up @@ -57,6 +58,15 @@ export const UIExtensionSchema = BaseSchema.extend({
},
}
: null),
...(targeting.tools
? {
[AssetIdentifier.Tools]: {
filepath: `${config.handle}-${AssetIdentifier.Tools}-${basename(targeting.tools)}`,
module: targeting.tools,
static: true,
},
}
: null),
},
}

Expand Down Expand Up @@ -125,6 +135,11 @@ const uiExtensionSpec = createExtensionSpecification({
return
}

// Skip static assets - they are copied after esbuild completes in rebuildContext
if (asset.static && asset.module) {
return
}

assets[identifier] = {
identifier: identifier as AssetIdentifier,
outputFileName: asset.filepath,
Expand All @@ -143,6 +158,26 @@ const uiExtensionSpec = createExtensionSpecification({
...(assetsArray.length ? {assets: assetsArray} : {}),
}
},
copyStaticAssets: async (config, directory, outputPath) => {
if (!isRemoteDomExtension(config)) return

await Promise.all(
config.extension_points.map((extensionPoint) => {
if (!('build_manifest' in extensionPoint)) return Promise.resolve()

return Object.entries(extensionPoint.build_manifest.assets).map(([_, asset]) => {
if (asset.static && asset.module) {
const sourceFile = joinPath(directory, asset.module)
const outputFilePath = joinPath(dirname(outputPath), asset.filepath)
return copyFile(sourceFile, outputFilePath).catch((error) => {
throw new Error(`Failed to copy static asset ${asset.module} to ${outputFilePath}: ${error.message}`)
})
}
return Promise.resolve()
})
}),
)
},
hasExtensionPointTarget: (config, requestedTarget) => {
return (
config.extension_points?.find((extensionPoint) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ describe('app-watcher-esbuild', () => {
const manager = new ESBuildContextManager(options)
await manager.createContexts([extension1])
const spyContext = vi.spyOn(manager.contexts.uid1![0]!, 'rebuild').mockResolvedValue({} as any)
const spyCopyStaticAssets = vi.spyOn(extension1, 'copyStaticAssets').mockResolvedValue()
const spyCopy = vi.spyOn(fs, 'copyFile').mockResolvedValue()

// When
await manager.rebuildContext(extension1)

// Then
expect(spyContext).toHaveBeenCalled()
expect(spyCopyStaticAssets).toHaveBeenCalledWith('/path/to/output/uid1/dist/test-ui-extension.js')
expect(spyCopy).toHaveBeenCalledWith('/path/to/output/uid1/dist', '/extensions/ui_extension_1/dist')
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,19 @@ export class ESBuildContextManager {
const context = this.contexts[extension.uid]
if (!context) return
await Promise.all(context.map((ctxt) => ctxt.rebuild()))
const devBundleOutputPath = extension.getOutputPathForDirectory(this.outputPath)

// Copy static assets after build completes
// Pass in an explicit output path because the extension.outputPath is not the same as the dev bundle output path.
await extension.copyStaticAssets(devBundleOutputPath)

// The default output path for a extension is now inside `.shopify/bundle/<ext_id>/dist`,
// all extensions output need to be under the same directory so that it can all be zipped together.

// But historically the output was inside each extension's directory.
// To avoid breaking flows that depend on this, we copy the output to the old location.
// This also makes it easier to access sourcemaps or other built artifacts.
const outputPath = dirname(extension.getOutputPathForDirectory(this.outputPath))
const outputPath = dirname(devBundleOutputPath)
const copyPath = dirname(extension.outputPath)
await copyFile(outputPath, copyPath)
}
Expand Down