Skip to content
Open
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
18 changes: 13 additions & 5 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
configuration: TConfiguration
configurationPath: string
outputPath: string
bundleRoot: string
handle: string
specification: ExtensionSpecification
uid: string
Expand Down Expand Up @@ -124,6 +125,10 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return this.specification.getOutputRelativePath?.(this) ?? ''
}

get localOutputPath() {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was being repeated everywhere so I made a getter for it

return joinPath(this.directory, this.outputRelativePath)
}

constructor(options: {
configuration: TConfiguration
configurationPath: string
Expand All @@ -139,9 +144,11 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
this.handle = this.buildHandle()
this.localIdentifier = this.handle
this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID`
this.outputPath = joinPath(this.directory, this.outputRelativePath)
this.outputPath = this.localOutputPath
this.uid = this.buildUIDFromStrategy()
this.devUUID = `dev-${this.uid}`
// We're not yet doing dev or deploy so the default bundle root is the extension directory
this.bundleRoot = this.directory
}

get draftMessages() {
Expand Down Expand Up @@ -328,17 +335,18 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
}

async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
this.bundleRoot = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
this.outputPath = joinPath(this.bundleRoot, this.outputRelativePath)
await this.build(options)

const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
await this.keepBuiltSourcemapsLocally(bundleInputPath)
await this.keepBuiltSourcemapsLocally(this.bundleRoot)
}

async copyIntoBundle(options: ExtensionBuildOptions, bundleDirectory: string, extensionUuid?: string) {
const defaultOutputPath = this.outputPath

this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)
this.bundleRoot = joinPath(bundleDirectory, this.getOutputFolderId(extensionUuid))
this.outputPath = joinPath(this.bundleRoot, this.outputRelativePath)

const buildMode = this.specification.buildConfig.mode

Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/services/build/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
}

// Always build into the extension's local directory (e.g. ext/dist/handle.js)
const localOutputPath = joinPath(extension.directory, extension.outputRelativePath)
const localOutputPath = extension.localOutputPath

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

Expand Down Expand Up @@ -175,7 +175,7 @@ export async function buildFunctionExtension(
await runTrampoline(extension.outputPath)
}

const projectOutputPath = joinPath(extension.directory, extension.outputRelativePath)
const projectOutputPath = extension.localOutputPath

if (
fileExistsSync(extension.outputPath) &&
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, relativePath} from '@shopify/cli-kit/node/path'
import type {BundleUIStep, BuildContext} from '../client-steps.js'

interface ExtensionPointWithBuildManifest {
Expand Down Expand Up @@ -32,25 +32,31 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont
(ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest,
)

const entries = extractBuiltAssetEntries(pointsWithManifest)
const outputDirRelative = relativePath(context.extension.bundleRoot, dirname(context.extension.outputPath))
const entries = extractBuiltAssetEntries(pointsWithManifest, outputDirRelative)
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}`.
* grouped by target. Returns a map of target → `{assetName: filepath}`. Filepaths
* are rewritten to be bundle-root-relative so downstream consumers (dev asset
* server, deploy server) resolve them consistently against the bundle root.
*/
function extractBuiltAssetEntries(extensionPoints: {target: string; build_manifest: BuildManifest}[]): {
function extractBuiltAssetEntries(
extensionPoints: {target: string; build_manifest: BuildManifest}[],
outputDirRelative: 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] = joinPath(outputDirRelative, asset.filepath)
}
if (Object.keys(assets).length > 0) entries[target] = assets
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('executeIncludeAssetsStep', () => {
mockExtension = {
directory: '/test/extension',
outputPath: '/test/output/extension.js',
bundleRoot: '/test/output',
} as ExtensionInstance

mockContext = {
Expand Down
27 changes: 14 additions & 13 deletions packages/app/src/cli/services/build/steps/include-assets-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {generateManifestFile} from './include-assets/generate-manifest.js'
import {copyByPattern} from './include-assets/copy-by-pattern.js'
import {copySourceEntry} from './include-assets/copy-source-entry.js'
import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js'
import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
import {joinPath, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
import {z} from 'zod'
import type {LifecycleStep, BuildContext} from '../client-steps.js'

Expand Down Expand Up @@ -68,7 +68,7 @@ const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, S
* then `pattern` and `static` entries run in parallel.
*
* When `generatesAssetsManifest` is `true`, a `manifest.json` file is written
* to the output directory after all inclusions complete. All entry types
* to the bundle root after all inclusions complete. All entry types
* contribute their copied output paths to the manifest. `configKey` entries
* with `anchor` and `groupBy` produce structured manifest entries; `pattern`
* and `static` entries contribute their paths under a `"files"` key.
Expand Down Expand Up @@ -107,25 +107,26 @@ type IncludeAssetsConfig = z.input<typeof IncludeAssetsConfigSchema>
*
* Iterates over `config.inclusions` and dispatches each entry by type:
*
* - `type: 'static'` — copy a file or directory into the output.
* - `type: 'static'` — copy a file or directory into the bundle root.
* - `type: 'configKey'` — resolve a path from the extension's
* config and copy into the output; silently skipped if absent.
* config and copy into the bundle root; silently skipped if absent.
* Runs sequentially to avoid filesystem race conditions.
* - `type: 'pattern'` — glob-based file selection from a source directory
* (defaults to extension root when `source` is omitted).
* (defaults to extension root when `baseDir` is omitted).
*
* All static assets copy to `extension.bundleRoot`. The `bundle_ui` step is
* responsible for placing built JS into `dist/`.
*
* When `generatesAssetsManifest` is `true`, all entry types contribute their
* copied output paths to `manifest.json`.
* copied output paths (bundle-root-relative) to `manifest.json`.
*/
export async function executeIncludeAssetsStep(
step: LifecycleStep,
context: BuildContext,
): Promise<{filesCopied: number}> {
const config = IncludeAssetsConfigSchema.parse(step.config)
const {extension, options} = context
// When outputPath is a file (e.g. index.js, index.wasm), the output directory is its
// parent. When outputPath has no extension, it IS the output directory.
const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath
const bundleRoot = extension.bundleRoot

const aggregatedPathMap = new Map<string, string | string[]>()
// Track basenames written across all configKey entries in this build to detect
Expand All @@ -145,7 +146,7 @@ export async function executeIncludeAssetsStep(
const result = await copyConfigKeyEntry({
key: entry.key,
baseDir: extension.directory,
outputDir,
outputDir: bundleRoot,
context,
destination: sanitizedDest,
usedBasenames,
Expand All @@ -164,7 +165,7 @@ export async function executeIncludeAssetsStep(

if (entry.type === 'pattern') {
const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory
const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir
const destinationDir = sanitizedDest ? joinPath(bundleRoot, sanitizedDest) : bundleRoot
const result = await copyByPattern(
{
sourceDir,
Expand All @@ -174,7 +175,7 @@ export async function executeIncludeAssetsStep(
},
options,
)
// result.outputPaths are relative to destinationDir; prefix with sanitizedDest for outer outputDir relativity
// result.outputPaths are relative to destinationDir; prefix with sanitizedDest for bundle-root relativity
const outputPaths = sanitizedDest
? result.outputPaths.map((outputPath) => joinPath(sanitizedDest, outputPath))
: result.outputPaths
Expand All @@ -187,7 +188,7 @@ export async function executeIncludeAssetsStep(
source: entry.source,
destination: sanitizedDest,
baseDir: extension.directory,
outputDir,
outputDir: bundleRoot,
},
options,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {getNestedValue, tokenizePath} from './copy-config-key-entry.js'
import {joinPath, dirname, extname} from '@shopify/cli-kit/node/path'
import {joinPath} from '@shopify/cli-kit/node/path'
import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {outputDebug} from '@shopify/cli-kit/node/output'
import type {BuildContext} from '../../client-steps.js'
Expand Down Expand Up @@ -121,18 +121,11 @@ export async function createOrUpdateManifestFile(
context: BuildContext,
entries: {[key: string]: unknown},
): Promise<void> {
const outputPath = context.extension.outputPath
/**
* Resolves the output directory from an extension's outputPath.
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
*/
const outputDir = extname(outputPath) ? dirname(outputPath) : outputPath

const manifestPath = joinPath(outputDir, 'manifest.json')

// Create the output directory
if (!(await fileExists(outputDir))) {
await mkdir(outputDir)
const bundleRoot = context.extension.bundleRoot
const manifestPath = joinPath(bundleRoot, 'manifest.json')

if (!(await fileExists(bundleRoot))) {
await mkdir(bundleRoot)
}

let existing: {[key: string]: unknown} = {}
Expand Down Expand Up @@ -163,7 +156,7 @@ export async function createOrUpdateManifestFile(
}

await writeFile(manifestPath, JSON.stringify(existing, null, 2))
outputDebug(`Updated manifest.json in ${outputDir}\n`, context.options.stdout)
outputDebug(`Updated manifest.json in ${bundleRoot}\n`, context.options.stdout)
}

/**
Expand Down
7 changes: 3 additions & 4 deletions packages/app/src/cli/services/dev/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
// NOTE: Always use `payloadOptions`, never `options` directly. This way we can mutate `payloadOptions` without
// affecting the original `options` object and we only need to care about `payloadOptions` in this function.

const bundlePath = payloadOptions.appWatcher.buildOutputPath
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath)
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions)
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions)
let extensions = payloadOptions.extensions.filter((ext) => ext.isPreviewable)

Expand Down Expand Up @@ -173,10 +172,10 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
// eslint-disable-next-line require-atomic-updates
payloadOptions.checkoutCartUrl = cartUrl
}
await payloadStore.addExtension(event.extension, bundlePath)
await payloadStore.addExtension(event.extension)
break
case EventType.Updated:
await payloadStore.updateExtension(event.extension, payloadOptions, bundlePath, {status, error})
await payloadStore.updateExtension(event.extension, payloadOptions, {status, error})
break
case EventType.Deleted:
payloadOptions.extensions = payloadOptions.extensions.filter((ext) => ext.devUUID !== event.extension.devUUID)
Expand Down
Loading
Loading