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
47 changes: 47 additions & 0 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ import {getDependencies, PackageManager, readAndParsePackageJson} from '@shopify
import {
fileExistsSync,
fileRealPath,
fileSize,
findPathUp,
glob,
readFileSync,
removeFileSync,
writeFileSync,
} from '@shopify/cli-kit/node/fs'
import {AbortError} from '@shopify/cli-kit/node/error'
import {renderInfo} from '@shopify/cli-kit/node/ui'
import {normalizeDelimitedString} from '@shopify/cli-kit/common/string'
import {JsonMapType} from '@shopify/cli-kit/node/toml'
import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array'
Expand Down Expand Up @@ -120,6 +123,7 @@ export const AppSchema = zod.object({
.optional(),
extension_directories: ExtensionDirectoriesSchema,
web_directories: zod.array(zod.string()).optional(),
static_root: zod.string().optional(),
})

/**
Expand Down Expand Up @@ -376,6 +380,10 @@ export class App<
TModuleSpec extends ExtensionSpecification = ExtensionSpecification,
> implements AppInterface<TConfig, TModuleSpec>
{
private static readonly MAX_STATIC_FILE_COUNT = 50
private static readonly MAX_STATIC_TOTAL_SIZE_MB = 2
private static readonly MAX_STATIC_TOTAL_SIZE_BYTES = App.MAX_STATIC_TOTAL_SIZE_MB * 1024 * 1024

name: string
idEnvironmentVariableName: 'SHOPIFY_API_KEY' = 'SHOPIFY_API_KEY' as const
directory: string
Expand Down Expand Up @@ -495,6 +503,7 @@ export class App<

async preDeployValidation() {
this.validateWebhookLegacyFlowCompatibility()
await this.validateStaticAssets()

const functionExtensionsWithUiHandle = this.allExtensions.filter(
(ext) => ext.isFunctionExtension && (ext.configuration as unknown as FunctionConfigType).ui?.handle,
Expand Down Expand Up @@ -607,6 +616,44 @@ export class App<
}
}

/**
* Validates that static assets folder is within limits.
* @throws When static root exceeds file count or size limits
*/
private async validateStaticAssets(): Promise<void> {
if (!isCurrentAppSchema(this.configuration)) return

const staticRoot = this.configuration.static_root
if (!staticRoot) return

const staticDir = joinPath(this.directory, staticRoot)
const files = await glob(joinPath(staticDir, '**/*'), {onlyFiles: true})

if (files.length > App.MAX_STATIC_FILE_COUNT) {
throw new AbortError(
`Static root folder contains ${files.length} files, which exceeds the limit of ${App.MAX_STATIC_FILE_COUNT} files.`,
`Reduce the number of files in "${staticRoot}" and try again.`,
)
}

const fileSizes = await Promise.all(files.map((file) => fileSize(file)))
const totalSize = fileSizes.reduce((sum, size) => sum + size, 0)
const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2)

if (totalSize > App.MAX_STATIC_TOTAL_SIZE_BYTES) {
throw new AbortError(
`Static root folder is ${totalSizeMB} MB, which exceeds the limit of ${App.MAX_STATIC_TOTAL_SIZE_MB} MB.`,
`Reduce the total size of files in "${staticRoot}" and try again.`,
)
}

const fileWord = files.length === 1 ? 'file' : 'files'
renderInfo({
headline: 'Static assets.',
body: [`Loading ${files.length} static ${fileWord} (${totalSizeMB} MB) from "${staticRoot}"`],
})
}

/**
* Validates that app-specific webhooks are not used with legacy install flow.
* This incompatibility exists because app-specific webhooks require declarative
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export interface AppConfigurationUsedByCli {
auth?: {
redirect_urls: string[]
}
static_root?: string
}
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// import {AppInterface} from '../models/app/app.js'
import {AppManifest} from '../models/app/app.js'
import {AssetUrlSchema, DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
import {MinimalAppIdentifiers} from '../models/organization.js'
Expand Down Expand Up @@ -32,6 +31,7 @@ export async function uploadToGCS(signedURL: string, filePath: string) {
const form = formData()
const buffer = readFileSync(filePath)
form.append('my_upload', buffer)
console.log(`🐝🐝🐝 Uploading file to GCS: ${signedURL}`)
await fetch(signedURL, {method: 'put', body: buffer, headers: form.getHeaders()}, 'slow-request')
}

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/cli/services/deploy/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {AppInterface, AppManifest} from '../../models/app/app.js'
import {Identifiers} from '../../models/app/identifiers.js'
import {installJavy} from '../function/build.js'
import {compressBundle, writeManifestToBundle} from '../bundle.js'
import {copyStaticAssetsToBundle} from '../static-assets.js'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {mkdir, rmdir} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
Expand Down Expand Up @@ -60,6 +61,8 @@ export async function bundleAndBuildExtensions(options: BundleOptions) {
showTimestamps: false,
})

await copyStaticAssetsToBundle(options.app, bundleDirectory)

if (options.bundlePath) {
await compressBundle(bundleDirectory, options.bundlePath)
}
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/cli/services/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
showReusedDevValues,
} from './context.js'
import {fetchAppPreviewMode} from './dev/fetch.js'
import {uploadStaticAssetsToGCS} from './static-assets.js'
import {installAppDependencies} from './dependencies.js'
import {DevConfig, DevProcesses, setupDevProcesses} from './dev/processes/setup-dev-processes.js'
import {frontAndBackendConfig} from './dev/processes/utils.js'
Expand Down Expand Up @@ -185,6 +186,12 @@ async function prepareForDev(commandOptions: DevOptions): Promise<DevConfig> {
async function actionsBeforeSettingUpDevProcesses(devConfig: DevConfig) {
await warnIfScopesDifferBeforeDev(devConfig)
await blockIfMigrationIncomplete(devConfig)
await devConfig.localApp.preDeployValidation()
await uploadStaticAssetsToGCS({
app: devConfig.localApp,
developerPlatformClient: devConfig.developerPlatformClient,
appId: devConfig.remoteApp,
})
}

/**
Expand Down Expand Up @@ -306,7 +313,7 @@ async function handleUpdatingOfPartnerUrls(
localApp.setDevApplicationURLs(newURLs)
} else {
// When running dev app urls are pushed directly to API Client config instead of creating a new app version
// so current app version and API Client config will have diferent url values.
// so current app version and API Client config will have different url values.
await updateURLs(newURLs, apiKey, developerPlatformClient, localApp)
}
}
Expand Down
117 changes: 117 additions & 0 deletions packages/app/src/cli/services/static-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {compressBundle, getUploadURL, uploadToGCS} from './bundle.js'
import {AppInterface, isCurrentAppSchema} from '../models/app/app.js'
import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
import {MinimalAppIdentifiers} from '../models/organization.js'
import {joinPath, relativePath} from '@shopify/cli-kit/node/path'
import {copyFile, glob, mkdir, rmdir} from '@shopify/cli-kit/node/fs'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {renderInfo} from '@shopify/cli-kit/node/ui'

/**
* Transforms a signed GCS URL for dev environment.
* Changes bucket from partners-extensions-scripts-bucket to partners-extensions-scripts-dev-bucket
* and changes path from /deployments/... to /hosted_app/...
*/
function transformSignedUrlForDev(signedURL: string): string {
const url = new URL(signedURL)

// Change bucket: partners-extensions-scripts-bucket -> partners-extensions-scripts-dev-bucket
url.hostname = url.hostname.replace('partners-extensions-scripts-bucket', 'partners-extensions-scripts-dev-bucket')

// Change path: /deployments/app_sources/... -> /hosted_app/...
// Extract the app ID and unique ID from the original path
const pathMatch = url.pathname.match(/\/deployments\/app_sources\/(\d+)\/([^/]+)\/(.+)/)
if (pathMatch) {
const [, appId, uniqueId, filename] = pathMatch
url.pathname = `/hosted_app/${appId}/${uniqueId}/${filename}`
}

return url.toString()
}

/**
* Copies static assets from the app's static_root directory to the bundle.
* @param app - The app interface
* @param bundleDirectory - The bundle directory to copy assets to
*/
export async function copyStaticAssetsToBundle(app: AppInterface, bundleDirectory: string): Promise<void> {
if (!isCurrentAppSchema(app.configuration)) return

const staticRoot = app.configuration.static_root
if (!staticRoot) return

const staticSourceDir = joinPath(app.directory, staticRoot)
const staticOutputDir = joinPath(bundleDirectory, 'static')

await mkdir(staticOutputDir)

const files = await glob(joinPath(staticSourceDir, '**/*'), {onlyFiles: true})

outputDebug(`Copying ${files.length} static assets from ${staticRoot} to bundle...`)

await Promise.all(
files.map(async (filepath) => {
const relativePathName = relativePath(staticSourceDir, filepath)
const outputFile = joinPath(staticOutputDir, relativePathName)
return copyFile(filepath, outputFile)
}),
)
}

export interface UploadStaticAssetsOptions {
app: AppInterface
developerPlatformClient: DeveloperPlatformClient
appId: MinimalAppIdentifiers
}

/**
* Bundles and uploads static assets to GCS.
* @param options - Upload options containing the app, developer platform client, and app identifiers
* @returns The GCS URL where assets were uploaded, or undefined if no static_root configured
*/
export async function uploadStaticAssetsToGCS(options: UploadStaticAssetsOptions): Promise<string | undefined> {
const {app, developerPlatformClient, appId} = options

if (!isCurrentAppSchema(app.configuration)) return undefined

const staticRoot = app.configuration.static_root
if (!staticRoot) return undefined

const staticSourceDir = joinPath(app.directory, staticRoot)
const files = await glob(joinPath(staticSourceDir, '**/*'), {onlyFiles: true})

if (files.length === 0) {
outputDebug(`No static assets found in ${staticRoot}`)
return undefined
}

// Create temp bundle directory
const bundleDirectory = joinPath(app.directory, '.shopify', 'static-assets-bundle')
await rmdir(bundleDirectory, {force: true})
await mkdir(bundleDirectory)

try {
// Copy static assets to bundle
await copyStaticAssetsToBundle(app, bundleDirectory)

// Compress the bundle
const bundlePath = joinPath(app.directory, '.shopify', 'static-assets.zip')
await compressBundle(bundleDirectory, bundlePath)

// Get signed URL, transform for dev bucket, and upload
const signedURL = await getUploadURL(developerPlatformClient, appId)
const devSignedURL = transformSignedUrlForDev(signedURL)
outputDebug(`Transformed URL for dev: ${devSignedURL}`)
await uploadToGCS(devSignedURL, bundlePath)

renderInfo({
headline: 'Static assets uploaded.',
body: [`Uploaded ${files.length} static assets from "${staticRoot}" to dev GCS bucket`],
})

return devSignedURL
} finally {
// Clean up temp directory
await rmdir(bundleDirectory, {force: true})
}
}
Loading