import archiver from 'archiver' ; import { IncomingMessage } from 'http' ; import { Readable , Writable } from 'stream' ; import { extract } from 'tar-fs' ; import { createGunzip , ZlibOptions } from 'zlib' ; import { CacheLayer } from '../..' ; import { CacheType , inflightURL , inflightUrlWithQuery , InstanceOptions , RequestTracingConfig , } from '../../HttpClient' ; import { IgnoreNotFoundRequestConfig , } from '../../HttpClient/middlewares/notFound' ; import { AppBundleLinked , AppFilesList , AppManifest } from '../../responses' ; import { IOContext } from '../../service/worker/runtime/typings' ; import { parseAppId , removeVersionFromAppId } from '../../utils' ; import { getFallbackFile , getMetaInfoKey , saveVersion , } from '../../utils/appsStaleIfError' ; import { InfraClient } from './InfraClient' ; const createRoutes = ( { account , workspace } ) => { const routes = { Acknowledge : ( app , service ) => `${ routes . App ( app ) }/acknowledge/${ service }` , App : ( app ) => `${ routes . Apps ( ) }/${ app }` , AppBundle : ( locator , path ) => `${ routes . AppOrRegistry ( locator ) }/bundle/${ path }` , AppOrRegistry : ( { name , version , build } ) => build ? `${ routes . Apps ( ) }/${ name }@${ version }+${ build }` : `${ routes . Registry ( ) }/${ name }/${ version }` , Apps : ( ) => `${ routes . Workspace }/apps` , Dependencies : ( ) => `${ routes . Workspace }/dependencies` , File : ( locator , path ) => `${ routes . Files ( locator ) }/${ path }` , FileFromApps : ( app , path ) => `${ routes . Workspace }/apps/${ app }/files/${ path }` , Files : ( locator ) => `${ routes . AppOrRegistry ( locator ) }/files` , Link : ( app ) => `${ routes . Workspace }/v2/links/${ app }` , Links : ( ) => `${ routes . Workspace }/links` , Master : `/${ account }/master` , Meta : ( ) => `${ routes . Workspace }/v2/apps` , Registry : ( ) => `${ routes . Master }/registry` , ResolveDependencies : ( ) => `${ routes . Workspace }/dependencies/_resolve` , ResolveDependenciesWithManifest : ( ) => `${ routes . Workspace }/v2/apps/_resolve` , Settings : ( app ) => `${ routes . App ( app ) }/settings` , Unlink : ( app ) => `${ routes . Links ( ) }/${ app }` , Workspace : `/${ account }/${ workspace }` , } ; return routes ; } ; const getVendorAndName = ( { id } ) => removeVersionFromAppId ( id ) ; const notFound = ( e ) => { if ( e . response && e . response . status === 404 ) { return { } ; } throw e ; } ; const zipObj = ( keys , values ) => { let idx = 0 ; const len = Math . min ( keys . length , values . length ) ; const out = { } ; while ( idx < len ) { out [ keys [ idx ] ] = values [ idx ] ; idx += 1 ; } return out ; } ; const workspaceFields = [ '_activationDate' , '_buildFeatures' , '_isRoot' , '_resolvedDependencies' , 'credentialType' , 'link' , 'name' , 'registry' , 'settingsSchema' , 'vendor' , 'version' , ] . join ( ',' ) ; interface AppLocator { name : string ; version : string ; build ? : string ; } export class Apps extends InfraClient { private _routes : ReturnType < typeof createRoutes > ; private diskCache : CacheLayer < string , any > | undefined ; private memoryCache : CacheLayer < string , any > | undefined ; private get routes ( ) { return this . _routes ; } constructor ( context , options ? ) { super ( 'apps@0.x' , context , options , true ) ; this . diskCache = options && options . diskCache ; this . memoryCache = options && options . memoryCache ; this . _routes = createRoutes ( context ) ; } public installApp = ( descriptor , tracingConfig ? ) => { if ( descriptor . startsWith ( 'infra:service-' ) ) { return this . installRuntime ( descriptor , tracingConfig ) ; } const metric = 'apps-install' ; return this . http . post < AppInstallResponse > ( this . routes . Apps ( ) , { id : descriptor } , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public uninstallApp = ( app , tracingConfig ? ) => { const metric = 'apps-uninstall' ; return this . http . delete ( this . routes . App ( app ) , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public acknowledgeApp = ( app , service , tracingConfig ? ) => { const metric = 'apps-ack' ; return this . http . put ( this . routes . Acknowledge ( app , service ) , null , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public link = async ( app , files , { zlib } = { } , tracingConfig ? ) => { if ( ! ( files [ 0 ] && files [ 0 ] . path ) ) { throw new Error ( 'Argument files must be an array of {path, content}, where content can be a String, a Buffer or a ReadableStream.' ) ; } const emptyChanges = files . filter ( file => ! file . content ) ; if ( emptyChanges . length > 0 ) { throw new Error ( `Missing content for paths: ${ emptyChanges . map ( e => e . path ) . join ( '; ' ) }` ) ; } const indexOfManifest = files . findIndex ( ( { path } ) => path === 'manifest.json' ) ; if ( indexOfManifest === - 1 ) { throw new Error ( 'No manifest.json file found in files.' ) ; } const zip = archiver ( 'zip' , { zlib } ) ; zip . on ( 'error' , ( e ) => { throw e ; } ) ; const metric = 'apps-link' ; const request = this . http . put < AppBundleLinked > ( this . routes . Link ( app ) , zip , { headers : { 'Content-Type' : 'application/zip' } , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; files . forEach ( ( { content , path } ) => zip . append ( content , { name : path } ) ) ; const finalize = zip . finalize ( ) ; try { const [ response ] = await Promise . all ( [ request , finalize ] ) ; response . bundleSize = zip . pointer ( ) ; return response ; } catch ( e ) { e . bundleSize = zip . pointer ( ) ; throw e ; } } ; public patch = async ( app , changes , { zlib } = { } , tracingConfig ? ) => { if ( ! ( changes [ 0 ] && changes [ 0 ] . path ) ) { throw new Error ( 'Argument changes must be an array of {path, content}, where content can be a String, a Buffer or a ReadableStream.' ) ; } const files = changes . filter ( change => ! ! change . content ) ; const deletedFiles = changes . filter ( change => ! change . content ) . map ( change => change . path ) . join ( ':' ) ; const zip = archiver ( 'zip' , { zlib } ) ; zip . on ( 'error' , ( e ) => { throw e ; } ) ; const metric = 'apps-patch' ; const request = this . http . patch ( this . routes . Link ( app ) , zip , { headers : { 'Content-Type' : 'application/zip' } , metric , params : { deletedFiles } , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; files . forEach ( ( { content , path } ) => zip . append ( content , { name : path } ) ) ; const finalize = zip . finalize ( ) ; const [ response ] = await Promise . all ( [ request , finalize ] ) ; return response ; } ; public unlink = ( app , tracingConfig ? ) => { return this . http . delete ( this . routes . Unlink ( app ) , { tracing : { requestSpanNameSuffix : 'apps-unlink' , ... tracingConfig ?. tracing , } , } ) ; } ; public unlinkAll = ( tracingConfig ? ) => { return this . http . delete ( this . routes . Links ( ) , { tracing : { requestSpanNameSuffix : 'apps-unlink-all' , ... tracingConfig ?. tracing , } , } ) ; } ; public saveAppSettings = ( app , settings , tracingConfig ? ) => { const headers = { 'Content-Type' : 'application/json' } ; const metric = 'apps-save' ; return this . http . put ( this . routes . Settings ( app ) , settings , { headers , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public listApps = ( { oldVersion , since , service } = { } , tracingConfig ? ) => { const params = { oldVersion , service , since , } ; const metric = 'apps-list' ; const inflightKey = inflightUrlWithQuery ; return this . http . get < AppsList > ( this . routes . Apps ( ) , { inflightKey , metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public listAppFiles = ( app , { prefix , nextMarker } = { } , tracingConfig ? ) => { const locator = parseAppId ( app ) ; const linked = ! ! locator . build ; const params = { marker : nextMarker , prefix , } ; const metric = linked ? 'apps-list-files' : 'registry-list-files' ; const inflightKey = inflightUrlWithQuery ; return this . http . get < AppFilesList > ( this . routes . Files ( locator ) , { inflightKey , metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public listLinks = ( tracingConfig ? ) => { const inflightKey = inflightURL ; const metric = 'apps-list-links' ; return this . http . get < string [ ] > ( this . routes . Links ( ) , { inflightKey , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public getAppFile = ( app , path , staleIfError ? , tracingConfig ? ) => { const { logger } = this . context ; const locator = parseAppId ( app ) ; const linked = ! ! locator . build ; const inflightKey = inflightURL ; if ( staleIfError && this . memoryCache ) { saveVersion ( app , this . memoryCache ) ; } const metric = linked ? 'apps-get-file' : 'registry-get-file' ; try { return this . http . getBuffer ( this . routes . File ( locator , path ) , { cacheable : linked ? CacheType . Memory : CacheType . Disk , inflightKey , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } catch ( error ) { logger . error ( { error , message : 'getAppFile failed' , app , path } ) ; if ( staleIfError && this . memoryCache ) { return getFallbackFile ( app , path , this . memoryCache , this ) ; } throw error ; } } ; public getAppJSON = < T extends object | null > ( app , path , nullIfNotFound ? , tracingConfig ? ) => { const locator = parseAppId ( app ) ; const linked = ! ! locator . build ; const inflightKey = inflightURL ; const metric = linked ? 'apps-get-json' : 'registry-get-json' ; return this . http . get < T > ( this . routes . File ( locator , path ) , { cacheable : linked ? CacheType . Memory : CacheType . Any , inflightKey , metric , nullIfNotFound , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } as IgnoreNotFoundRequestConfig ) ; } ; public getFileFromApps = < T extends object | null > ( app , path , nullIfNotFound ? , tracingConfig ? ) => { const inflightKey = inflightURL ; const metric = 'get-file-from-apps' ; return this . http . get < T > ( this . routes . FileFromApps ( app , path ) , { cacheable : CacheType . Memory , inflightKey , metric , nullIfNotFound , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } as IgnoreNotFoundRequestConfig ) ; } ; public getAppFileStream = ( app , path , tracingConfig ? ) : Promise < IncomingMessage > => { const locator = parseAppId ( app ) ; const metric = locator . build ? 'apps-get-file-s' : 'registry-get-file-s' ; return this . http . getStream ( this . routes . File ( locator , path ) , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public getApp = ( app , tracingConfig ? ) => { const metric = 'apps-get-app' ; const inflightKey = inflightURL ; return this . http . get < AppManifest > ( this . routes . App ( app ) , { inflightKey , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public getAppSettings = ( app , tracingConfig ? ) => { const inflightKey = inflightURL ; const metric = 'apps-get-settings' ; return this . http . get < any > ( this . routes . Settings ( app ) , { inflightKey , metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public getAllAppsSettings = ( listAppsOptions = { } , tracingConfig ? ) : Promise < AppsSettings > => { return this . listApps ( listAppsOptions , tracingConfig ) . then ( ( { data : installedApps } ) => { const names = installedApps . map ( getVendorAndName ) ; const settingsPromises = names . map ( vendorAndName => this . getAppSettings ( vendorAndName , tracingConfig ) . catch ( notFound ) ) ; return Promise . all ( settingsPromises ) . then ( ( settings ) => { return zipObj ( names , settings ) ; } ) ; } ) ; } ; public getAppBundle = ( app , bundlePath , generatePackageJson , tracingConfig ? ) : Promise < Readable > => { const locator = parseAppId ( app ) ; const params = generatePackageJson && { _packageJSONEngine : 'npm' , _packageJSONFilter : 'vtex.render-builder@x' } ; const metric = locator . build ? 'apps-get-bundle' : 'registry-get-bundle' ; return this . http . getStream ( this . routes . AppBundle ( locator , bundlePath ) , { headers : { Accept : 'application/x-gzip' , 'Accept-Encoding' : 'gzip' , } , metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public unpackAppBundle = ( app , bundlePath , unpackPath , generatePackageJson , tracingConfig ? ) : Promise < Writable > => { return this . getAppBundle ( app , bundlePath , generatePackageJson , tracingConfig ) . then ( stream => stream . pipe ( createGunzip ( ) ) . pipe ( extract ( unpackPath ) ) ) ; } ; public getAppsMetaInfos = async ( filter ? , staleWhileRevalidate = true , tracingConfig ? ) => { const { account , production , recorder , workspace , logger } = this . context ; const metric = 'get-apps-meta' ; const inflightKey = inflightURL ; const key = getMetaInfoKey ( account , workspace ) ; const cachedResponse = production && staleWhileRevalidate ? await this . diskCache ?. get ( key ) : undefined ; if ( cachedResponse && recorder ) { recorder . record ( cachedResponse . headers ) ; } const metaInfoPromise = this . http . getRaw < WorkspaceMetaInfo > ( this . routes . Meta ( ) , { ignoreRecorder : Boolean ( cachedResponse ) , inflightKey , metric , params : { fields : workspaceFields } , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) . then ( response => { const { data , headers : responseHeaders } = response ; if ( this . diskCache && production ) { this . diskCache . set ( key , { appsMetaInfo : data . apps || [ ] , headers : responseHeaders , } ) ; } return response ; } ) ; let appsMetaInfo ; if ( cachedResponse ) { appsMetaInfo = cachedResponse . appsMetaInfo ; metaInfoPromise . catch ( error => { logger . warn ( { message : 'Unable to update stale cache' , error } ) ; } ) ; } else { appsMetaInfo = await metaInfoPromise . then ( response => response . data . apps ) ; } if ( filter ) { return appsMetaInfo . filter ( appMeta => appMeta ?. _resolvedDependencies ?. filter ) ; } return appsMetaInfo ; } ; public getDependencies = ( filter = '' , tracingConfig ? ) => { const params = { filter } ; const metric = 'apps-get-deps' ; const inflightKey = inflightUrlWithQuery ; return this . http . get < Record < string , string [ ] > > ( this . routes . Dependencies ( ) , { inflightKey , metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public updateDependencies = ( tracingConfig ? ) => { const metric = 'apps-update-deps' ; return this . http . put < Record < string , string [ ] > > ( this . routes . Dependencies ( ) , null , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public updateDependency = ( name , version , registry , tracingConfig ? ) => { const metric = 'apps-update-dep' ; return this . http . patch ( this . routes . Apps ( ) , [ { name , version , registry } ] , { metric , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public resolveDependencies = ( apps , registries , filter = '' , tracingConfig ? ) => { const params = { apps , registries , filter } ; const metric = 'apps-resolve-deps' ; const inflightKey = inflightUrlWithQuery ; return this . http . get ( this . routes . ResolveDependencies ( ) , { inflightKey , metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; public resolveDependenciesWithManifest = ( manifest , filter = '' , tracingConfig ? ) => { const params = { filter } ; const metric = 'apps-resolve-deps-m' ; return this . http . post < Record < string , string [ ] > > ( this . routes . ResolveDependenciesWithManifest ( ) , manifest , { metric , params , tracing : { requestSpanNameSuffix : metric , ... tracingConfig ?. tracing , } , } ) ; } ; private installRuntime = ( descriptor , tracingConfig ? ) => { const { account , workspace } = this . context ; const [ name , version ] = descriptor . split ( '@' ) ; return this . http . patch ( `http://apps.aws-us-east-1.vtex.io/${ account }/${ workspace }/apps` , [ { name , version , } , ] , { tracing : { requestSpanNameSuffix : 'apps-install-runtime' , ... tracingConfig ?. tracing , } , } ) ; } ; } interface ZipOptions { zlib ? : ZlibOptions ; } export interface AppMetaInfo { id : string ; settingsSchema ? : Record < string , any > ; _resolvedDependencies : Record < string , string > ; _isRoot : boolean ; _buildFeatures : Record < string , string [ ] > ; } export interface WorkspaceMetaInfo { apps : AppMetaInfo [ ] ; } interface StaleWorkspaceMetaInfo { appsMetaInfo : AppMetaInfo [ ] ; headers : any ; } export interface AppsListItem { app : string ; id : string ; location : string ; } export interface AppsList { data : AppsListItem [ ] ; } export interface Change { path : string ; content : string | Readable | Buffer ; } export interface ListAppsOptions { oldVersion ? : string ; context ? : string [ ] ; since ? : string ; service ? : string ; } export interface ListFilesOptions { prefix ? : string ; context ? : string [ ] ; nextMarker ? : string ; } export interface AppsSettings { [ app ] : any ; } export interface AppInstallResponse { message : string ; }