Skip to content

Commit ba7782c

Browse files
committed
Fix ui_extension manifest.json
1 parent 576467d commit ba7782c

12 files changed

Lines changed: 101 additions & 102 deletions

File tree

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
4747
configuration: TConfiguration
4848
configurationPath: string
4949
outputPath: string
50+
bundleRoot: string
5051
handle: string
5152
specification: ExtensionSpecification
5253
uid: string
@@ -124,6 +125,10 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
124125
return this.specification.getOutputRelativePath?.(this) ?? ''
125126
}
126127

128+
get localOutputPath() {
129+
return joinPath(this.directory, this.outputRelativePath)
130+
}
131+
127132
constructor(options: {
128133
configuration: TConfiguration
129134
configurationPath: string
@@ -139,9 +144,11 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
139144
this.handle = this.buildHandle()
140145
this.localIdentifier = this.handle
141146
this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID`
142-
this.outputPath = joinPath(this.directory, this.outputRelativePath)
147+
this.outputPath = this.localOutputPath
143148
this.uid = this.buildUIDFromStrategy()
144149
this.devUUID = `dev-${this.uid}`
150+
// We're not yet doing dev or deploy so the default bundle root is the extension directory
151+
this.bundleRoot = this.directory
145152
}
146153

147154
get draftMessages() {
@@ -328,17 +335,18 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
328335
}
329336

330337
async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
331-
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
338+
this.bundleRoot = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
339+
this.outputPath = joinPath(this.bundleRoot, this.outputRelativePath)
332340
await this.build(options)
333341

334-
const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
335-
await this.keepBuiltSourcemapsLocally(bundleInputPath)
342+
await this.keepBuiltSourcemapsLocally(this.bundleRoot)
336343
}
337344

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

341-
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid)
348+
this.bundleRoot = joinPath(bundleDirectory, this.getOutputFolderId(extensionUuid))
349+
this.outputPath = joinPath(this.bundleRoot, this.outputRelativePath)
342350

343351
const buildMode = this.specification.buildConfig.mode
344352

packages/app/src/cli/services/build/extension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
6868
}
6969

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

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

@@ -175,7 +175,7 @@ export async function buildFunctionExtension(
175175
await runTrampoline(extension.outputPath)
176176
}
177177

178-
const projectOutputPath = joinPath(extension.directory, extension.outputRelativePath)
178+
const projectOutputPath = extension.localOutputPath
179179

180180
if (
181181
fileExistsSync(extension.outputPath) &&

packages/app/src/cli/services/build/steps/bundle-ui-step.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {createOrUpdateManifestFile} from './include-assets/generate-manifest.js'
22
import {buildUIExtension} from '../extension.js'
33
import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js'
44
import {copyFile} from '@shopify/cli-kit/node/fs'
5-
import {dirname} from '@shopify/cli-kit/node/path'
5+
import {dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path'
66
import type {BundleUIStep, BuildContext} from '../client-steps.js'
77

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

35-
const entries = extractBuiltAssetEntries(pointsWithManifest)
35+
const outputDirRelative = relativePath(context.extension.bundleRoot, dirname(context.extension.outputPath))
36+
const entries = extractBuiltAssetEntries(pointsWithManifest, outputDirRelative)
3637
if (Object.keys(entries).length > 0) {
3738
await createOrUpdateManifestFile(context, entries)
3839
}
3940
}
4041

4142
/**
4243
* Extracts built asset filepaths from `build_manifest` on each extension point,
43-
* grouped by target. Returns a map of target → `{assetName: filepath}`.
44+
* grouped by target. Returns a map of target → `{assetName: filepath}`. Filepaths
45+
* are rewritten to be bundle-root-relative so downstream consumers (dev asset
46+
* server, deploy server) resolve them consistently against the bundle root.
4447
*/
45-
function extractBuiltAssetEntries(extensionPoints: {target: string; build_manifest: BuildManifest}[]): {
48+
function extractBuiltAssetEntries(
49+
extensionPoints: {target: string; build_manifest: BuildManifest}[],
50+
outputDirRelative: string,
51+
): {
4652
[target: string]: {[assetName: string]: string}
4753
} {
4854
const entries: {[target: string]: {[assetName: string]: string}} = {}
4955
for (const {target, build_manifest: buildManifest} of extensionPoints) {
5056
if (!buildManifest?.assets) continue
5157
const assets: {[name: string]: string} = {}
5258
for (const [name, asset] of Object.entries(buildManifest.assets)) {
53-
if (asset?.filepath) assets[name] = asset.filepath
59+
if (asset?.filepath) assets[name] = joinPath(outputDirRelative, asset.filepath)
5460
}
5561
if (Object.keys(assets).length > 0) entries[target] = assets
5662
}

packages/app/src/cli/services/build/steps/include-assets-step.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('executeIncludeAssetsStep', () => {
1818
mockExtension = {
1919
directory: '/test/extension',
2020
outputPath: '/test/output/extension.js',
21+
bundleRoot: '/test/output',
2122
} as ExtensionInstance
2223

2324
mockContext = {

packages/app/src/cli/services/build/steps/include-assets-step.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {generateManifestFile} from './include-assets/generate-manifest.js'
22
import {copyByPattern} from './include-assets/copy-by-pattern.js'
33
import {copySourceEntry} from './include-assets/copy-source-entry.js'
44
import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js'
5-
import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
5+
import {joinPath, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
66
import {z} from 'zod'
77
import type {LifecycleStep, BuildContext} from '../client-steps.js'
88

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

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

165166
if (entry.type === 'pattern') {
166167
const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory
167-
const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir
168+
const destinationDir = sanitizedDest ? joinPath(bundleRoot, sanitizedDest) : bundleRoot
168169
const result = await copyByPattern(
169170
{
170171
sourceDir,
@@ -174,7 +175,7 @@ export async function executeIncludeAssetsStep(
174175
},
175176
options,
176177
)
177-
// result.outputPaths are relative to destinationDir; prefix with sanitizedDest for outer outputDir relativity
178+
// result.outputPaths are relative to destinationDir; prefix with sanitizedDest for bundle-root relativity
178179
const outputPaths = sanitizedDest
179180
? result.outputPaths.map((outputPath) => joinPath(sanitizedDest, outputPath))
180181
: result.outputPaths
@@ -187,7 +188,7 @@ export async function executeIncludeAssetsStep(
187188
source: entry.source,
188189
destination: sanitizedDest,
189190
baseDir: extension.directory,
190-
outputDir,
191+
outputDir: bundleRoot,
191192
},
192193
options,
193194
)

packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {getNestedValue, tokenizePath} from './copy-config-key-entry.js'
2-
import {joinPath, dirname, extname} from '@shopify/cli-kit/node/path'
2+
import {joinPath} from '@shopify/cli-kit/node/path'
33
import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs'
44
import {outputDebug} from '@shopify/cli-kit/node/output'
55
import type {BuildContext} from '../../client-steps.js'
@@ -121,18 +121,11 @@ export async function createOrUpdateManifestFile(
121121
context: BuildContext,
122122
entries: {[key: string]: unknown},
123123
): Promise<void> {
124-
const outputPath = context.extension.outputPath
125-
/**
126-
* Resolves the output directory from an extension's outputPath.
127-
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
128-
*/
129-
const outputDir = extname(outputPath) ? dirname(outputPath) : outputPath
130-
131-
const manifestPath = joinPath(outputDir, 'manifest.json')
132-
133-
// Create the output directory
134-
if (!(await fileExists(outputDir))) {
135-
await mkdir(outputDir)
124+
const bundleRoot = context.extension.bundleRoot
125+
const manifestPath = joinPath(bundleRoot, 'manifest.json')
126+
127+
if (!(await fileExists(bundleRoot))) {
128+
await mkdir(bundleRoot)
136129
}
137130

138131
let existing: {[key: string]: unknown} = {}
@@ -163,7 +156,7 @@ export async function createOrUpdateManifestFile(
163156
}
164157

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

169162
/**

packages/app/src/cli/services/dev/extension.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
124124
// NOTE: Always use `payloadOptions`, never `options` directly. This way we can mutate `payloadOptions` without
125125
// affecting the original `options` object and we only need to care about `payloadOptions` in this function.
126126

127-
const bundlePath = payloadOptions.appWatcher.buildOutputPath
128-
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath)
127+
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions)
129128
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions)
130129
let extensions = payloadOptions.extensions.filter((ext) => ext.isPreviewable)
131130

@@ -173,10 +172,10 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
173172
// eslint-disable-next-line require-atomic-updates
174173
payloadOptions.checkoutCartUrl = cartUrl
175174
}
176-
await payloadStore.addExtension(event.extension, bundlePath)
175+
await payloadStore.addExtension(event.extension)
177176
break
178177
case EventType.Updated:
179-
await payloadStore.updateExtension(event.extension, payloadOptions, bundlePath, {status, error})
178+
await payloadStore.updateExtension(event.extension, payloadOptions, {status, error})
180179
break
181180
case EventType.Deleted:
182181
payloadOptions.extensions = payloadOptions.extensions.filter((ext) => ext.devUUID !== event.extension.devUUID)

0 commit comments

Comments
 (0)