From e619b30b6ca322ad22dceda3bd088f5c1e4ebfc8 Mon Sep 17 00:00:00 2001 From: Elana Kopelevich Date: Fri, 16 Jan 2026 17:22:00 -0700 Subject: [PATCH] WIP: handle app static assets in CLI --- packages/app/src/cli/models/app/app.ts | 47 +++++++ .../specifications/types/app_config.ts | 1 + packages/app/src/cli/services/bundle.ts | 2 +- .../app/src/cli/services/deploy/bundle.ts | 3 + packages/app/src/cli/services/dev.ts | 9 +- .../app/src/cli/services/static-assets.ts | 117 ++++++++++++++++++ 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/cli/services/static-assets.ts diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index ead77d10470..cc43008a0a0 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -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' @@ -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(), }) /** @@ -376,6 +380,10 @@ export class App< TModuleSpec extends ExtensionSpecification = ExtensionSpecification, > implements AppInterface { + 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 @@ -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, @@ -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 { + 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 diff --git a/packages/app/src/cli/models/extensions/specifications/types/app_config.ts b/packages/app/src/cli/models/extensions/specifications/types/app_config.ts index 0ba266ecb6d..fde0a28b008 100644 --- a/packages/app/src/cli/models/extensions/specifications/types/app_config.ts +++ b/packages/app/src/cli/models/extensions/specifications/types/app_config.ts @@ -28,4 +28,5 @@ export interface AppConfigurationUsedByCli { auth?: { redirect_urls: string[] } + static_root?: string } diff --git a/packages/app/src/cli/services/bundle.ts b/packages/app/src/cli/services/bundle.ts index c3decdbe883..64b773d4008 100644 --- a/packages/app/src/cli/services/bundle.ts +++ b/packages/app/src/cli/services/bundle.ts @@ -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' @@ -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') } diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index cc18cddcf00..2b9710918d4 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -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' @@ -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) } diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index af0af066266..6edfebd678f 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -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' @@ -185,6 +186,12 @@ async function prepareForDev(commandOptions: DevOptions): Promise { 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, + }) } /** @@ -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) } } diff --git a/packages/app/src/cli/services/static-assets.ts b/packages/app/src/cli/services/static-assets.ts new file mode 100644 index 00000000000..c5a1a8f0cb8 --- /dev/null +++ b/packages/app/src/cli/services/static-assets.ts @@ -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 { + 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 { + 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}) + } +}