Using Virtual Application Paths ( ~ ) in Any File!
posted on Friday, December 30, 2005 by bobby @ 6:00 am
Earlier today, I posted an article about using virtual application paths inside a css file. While that approach is indeed a step in the right direction, I wasn't quite happy w/ the way I had implemented it - specifically the fact that a new file would be spawned alongside the real css file, with a "_resolved.css" appendage.

This approach only works if you give ASP.NET full access to write, and even then, it's easy for a file to get locked, resulting in a fatty exception.

Jon Gilkison had a great idea to use HttpHandlers to intercept the request & intelligently cache the resolved css in memory. I took his code and slightly modified it to what you see below. This is by far the best approach. Props to Jon for writing the bulk of HttpHandler code.

Once employed, this is what your css links in markup will look like:
Copy code to clipboard in IE or select code for Firefox
<link rel="stylesheet" href="~/resources/ui/all.css.ashx" />
<link rel="stylesheet" href="~/resources/ui/msie.css.ashx" />


Notice that the only change is the additional ".ashx" applied to the end of the file name. The ".ashx" exists to distinguish this resource from other ".js" files that should not get resolved. No controls to use or register - very clean & mad stupid dope.

This is the FileResolver class - the HttpHandler responsible for intercepting any calls requesting (in this case) .css.ashx files (could be any file - .myExtension.ashx).

Warning: Lots of code below. Copy, build, rock on.
Copy code to clipboard in IE or select code for Firefox
namespace Oxford.Web
{
    public class FileResolver : IHttpHandler
    {
        /// <summary>
        /// File cache item used to store file content & date entered into cache
        /// </summary>
        internal class FileCacheItem
        {
            internal string Content;
            internal DateTime DateEntered = DateTime.Now;

            internal FileCacheItem(string content)
            {
                this.Content = content;
            }
        }

        private FileCacheItem UpdateFileCache(HttpContext context, string filePath)
        {
            string content;

            using(FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                using(StreamReader sr = new StreamReader(fs))
                {
                    content = sr.ReadToEnd();
                    sr.Close();
                }

                fs.Close();
            }

            //Get absolute application path
            string relAppPath = HttpRuntime.AppDomainAppVirtualPath;
            if(!relAppPath.EndsWith("/"))
                relAppPath += "/";

            //Replace virtual paths w/ absolute path
            content = content.Replace("~/", relAppPath);

            FileCacheItem ci = new FileCacheItem(content);

            //Store the FileCacheItem in cache w/ a dependency on the file changing
            CacheDependency cd = new CacheDependency(filePath);
            context.Cache.Insert(filePath, ci, cd);
            return ci;
        }

        public void ProcessRequest(HttpContext context)
        {
            string absFilePath = context.Request.PhysicalPath.Replace(".ashx", "");
            //Ensure ~/ in file path is resolved
            if(absFilePath.IndexOf("~\\") > -1)
                absFilePath = absFilePath.Replace("~", "").Replace("\\\\", "\\");

            if(!File.Exists(absFilePath))
            {
                context.Response.StatusCode = 404;
                return;
            }

            FileCacheItem ci = (FileCacheItem)context.Cache[absFilePath];
            if(ci != null)
            {
                if(context.Request.Headers["If-Modified-Since"] != null)
                {
                    try
                    {
                        DateTime date = DateTime.Parse(context.Request.Headers["If-Modified-Since"]);

                        if(ci.DateEntered.ToString() == date.ToString())
                        {
                            //Don't do anything, nothing has changed since last request
                            context.Response.StatusCode = 304;
                            context.Response.StatusDescription = "Not Modified";
                            context.Response.End();
                            return;
                        }
                    }
                    catch(Exception){}
                }
                else
                {
                    //In the event that the browser doesn't automatically have this header, add it
                    context.Response.AddHeader("If-Modified-Since", ci.DateEntered.ToString());
                }
            }
            else
            {
                //Cache item not found, update cache
                ci = UpdateFileCache(context, absFilePath);
            }

            context.Response.Cache.SetLastModified(ci.DateEntered);
            context.Response.ContentType = "text/" + GetContentType(Path.GetExtension(absFilePath));
            context.Response.Write(ci.Content);
            context.Response.End();
        }

        /// <summary>
        /// Gets the appropriate content type for a specified extension
        /// </summary>
        private string GetContentType(string ext)
        {
            switch(ext.ToLower())
            {
                case ".css":
                    return "css";
                    break;
                case ".xml":
                    return "xml";
                    break;
                case ".js":
                    return "javascript";
                    break;
                default:
                    return "plain";
                    break;
            }
        }

        #region IHttpHandler Members

        public bool IsReusable
        {
            get
            {
                return true;
            }
        }

        #endregion
    }
}


Since we are using HttpHandlers, you will need to add one entry to your web.config file.
Copy code to clipboard in IE or select code for Firefox
...
<httpHandlers>
    <add verb="GET" path="*.css.ashx" type="Oxford.Web.FileResolver,Oxford.Web" />
</httpHandlers>
...


Another benefit to this approach is that you can use it for any file type, not just css. Simply add additional line entries to your web.config for other types, like .js. I tested with a javascript file and it worked like a charm.
Copy code to clipboard in IE or select code for Firefox
...
<httpHandlers>
    <add verb="GET" path="*.css.ashx" type="Oxford.Web.FileResolver,Oxford.Web" />
    <add verb="GET" path="*.js.ashx" type="Oxford.Web.FileResolver,Oxford.Web" />
</httpHandlers>
...


Likewise, to add the script tag to your markup would look like this:
Copy code to clipboard in IE or select code for Firefox
<script lang="javascript" src="~/resources/script/script.js.ashx"> </script>


"You take yourself out of the game, you start talking about puppy dogs and ice cream and of course it's going to end up on the friendship tip." - DoubleDown, Swingers

CommentsComments
posted on Tuesday, January 03, 2006  by Anonymous @ 10:34 AM

This is a great idea, and is similar to the resource embedding technqiue that asp.net 2.0 offers ONLY for control assemblies. This makes it much easier where you dont want to wrap everysing up in controls.

Questions: This line of code fails for all resources
Copy code to clipboard in IE or select code for Firefox
string absFilePath = context.Request.PhysicalPath.Replace(".ashx", "");

if(!File.Exists(absFilePath))
{
    context.Response.StatusCode = 404;
    return;
}


The file never exists for any resources because the ~ is still in the path.

2. This technique (if i can get it working) would also allow css files that have images hrefs inside them to also use the .ashx technqiue.

Regards

Gerard
posted on Tuesday, January 03, 2006  by bobby @ 11:10 AM

Hey Gerard,

You are correct. You will need to use a real virtual or relative path for your <link /> tag. This works for me because I employ this trick on my site.

On the other hand, if you wanted to use a resolved path all the time w/out employing the latter technique, you could easily turn this into a control. See the first part of this post.

Cheers,
Bobby
posted on Tuesday, January 03, 2006  by Anonymous @ 11:52 AM

Bobby,

thansk for quick reply.

Firstly, i understand what you are doing in the other post you refer me to.

Does it not make sense however to simply chane the code to work with the "~" automatically. This works but i have not tested it fully on sites that dont have a virtual dir at the start (i.e production deployment scenario).
Copy code to clipboard in IE or select code for Firefox
public void ProcessRequest(HttpContext context)
{
    //Create full physical path( assuming "~" used)
    //build prefix
    string appRootPath = context.Request.PhysicalApplicationPath;
    //build suffix
    int startIndex = context.Request.PhysicalPath.IndexOf("~") + 2;
    int endIndex = context.Request.PhysicalPath.Length;
    string resourceAbsolutePath = context.Request.PhysicalPath.Substring(startIndex, endIndex - startIndex);

    string absFilePath = (appRootPath + resourceAbsolutePath);
    absFilePath = absFilePath.Replace(".ashx", "");
}

posted on Tuesday, January 03, 2006  by bobby @ 12:10 PM

I have to retract my previous statement - this should work regardless of the "resolve path anywhere" trick. I tried this on a regular aspx page (one that inherits from System.Web.UI.Page) and the context.Request.PhysicalPath property returns a fully resolved path to the proper file. I didn't do anything special at all - it simply works.

Of course you can add that code, but it doesn't seem you like you should really have to.

Cheers,
Bobby
posted on Tuesday, January 03, 2006  by Anonymous @ 12:15 PM

Bobby,

Now i see what you meant at the start. sorted - sorry about confusion.

i am using this for a complex Ajax based project. It is really doing ym head in.
Almost considering to port the current implemenation to Flash (using the Eclipse IDE), as its such a dog doing this untyped code.
posted on Tuesday, January 03, 2006  by bobby @ 12:29 PM

heh, no worries. Yeah, I know what you mean about Ajax. I decided to just wait for Atlas to come out instead of trying to do anything complex in Ajax just yet.
posted on Thursday, January 05, 2006  by bobby @ 3:27 AM

Another reason the ~/ may have not resolved properly in the <link /> tag is because the <head /> tag may not have had the runat="server" attribute tied to it.

The <head /> tag must be interpreted by the server to use virtual app paths for the href attribute. If you cannot do this within your application, you must use a real relative or absolute path.
posted on Thursday, January 05, 2006  by Gerard @ 8:57 PM

Hey Bobby,

i have rewritten all the code to allow the "~" technique to be used for text AND binary files.

I needed this because i wanted my binaries to also do this.

The caching can be turned on or off.
Typically you want the text files to be cached but NOT the binary. This is because the text can have embedded paths that need to be converted (which i now do also), and hence you may as well do this.
Its important to be able to turn caching complete off during development, because when you change a file (like JS) you want to see its effect. Caching is a HUGE pain here.

i can send it to you and you can put it on your blog if you want mate. Just give me some credit if you want :)

Gerard
posted on Friday, January 06, 2006  by bobby @ 5:29 AM

Gerard,

Feel free to post the code as a comment, just use the [pre] tags and the code will automatically be color coded.

Cheers,
Bobby
posted on Friday, January 06, 2006  by Anonymous @ 7:20 AM

Bobby,

Heres my altered code. It allows any resource to use the ashx extension with the "~" suffix.
Bloody useful for any web project IMHO.
Copy code to clipboard in IE or select code for Firefox
using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Caching;
using System.IO;

namespace Ubu.Framework.Web
{

    public class FileResolver : IHttpHandler
    {

        #region Caching
        /// &lt;summary&gt;
        /// File cache item used to store file content & date entered into cache
        /// &lt;/summary&gt;
        /// &lt;example&gt;
        /// HTML:
        /// &lt;link rel="stylesheet" href="~/resources/ui/all.css.ashx" /&gt;
        /// web.config:
        /// &lt;httpHandlers&gt;
        /// &lt;add verb="GET" path="*.css.ashx" type="Oxford.Web.FileResolver,Oxford.Web" /&gt;
        /// &lt;add verb="GET" path="*.js.ashx" type="Oxford.Web.FileResolver,Oxford.Web" /&gt;
        /// &lt;/httpHandlers&gt;
        /// &lt;/example&gt;
        internal class FileCacheItemImage
        {
            internal byte[] Content;
            internal DateTime DateEntered = DateTime.Now;

            internal FileCacheItemImage(byte[] content)
            {
                this.Content = content;
            }

        }

        private FileCacheItemImage UpdateFileCacheImage(HttpContext context, byte[] content, string cacheKey)
        {

            FileCacheItemImage ci = new FileCacheItemImage(content);

            //Store the FileCacheItem in cache w/ a dependency on the file changing
            CacheDependency cd = new CacheDependency(cacheKey);
            context.Cache.Insert(cacheKey, ci, cd);
            return ci;
        }




        internal class FileCacheItemText
        {
            internal string Content;
            internal DateTime DateEntered = DateTime.Now;

            internal FileCacheItemText(string content)
            {
                this.Content = content;
            }


        }

        private FileCacheItemText UpdateFileCacheText(HttpContext context, string content, string cacheKey)
        {

            FileCacheItemText ci = new FileCacheItemText(content);

            //Store the FileCacheItem in cache w/ a dependency on the file changing
            CacheDependency cd = new CacheDependency(cacheKey);
            context.Cache.Insert(cacheKey, ci, cd);
            return ci;
        }

        public void ClearCache(HttpContext context)
        {
            List&lt;string&gt; keyList = new List&lt;string&gt;();
            System.Collections.IDictionaryEnumerator CacheEnum = context.Cache.GetEnumerator();
            while (CacheEnum.MoveNext())
            {
                keyList.Add(CacheEnum.Key.ToString());
            }
            foreach (string key in keyList)
            {
                context.Cache.Remove(key);
            }
        }

        #endregion

        public void ProcessRequest(HttpContext context)
        {


            /// Create full physical path( assuming "~" used always ) to normal path
            /// so we get grab the file.
            // build prefix
            string appRootPath = context.Request.PhysicalApplicationPath;
            // build suffix
            int startIndex = context.Request.PhysicalPath.IndexOf("~") + 2;
            int endIndex = context.Request.PhysicalPath.Length;
            string resourceAbsolutePath = context.Request.PhysicalPath.Substring(startIndex, endIndex - startIndex);


            string absFilePath = (appRootPath + resourceAbsolutePath);
            absFilePath = absFilePath.Replace(".ashx", "");

            if (!File.Exists(absFilePath))
            {
                context.Response.StatusCode = 404;
                return;
            }

            if (GetContentType(Path.GetExtension(absFilePath)).Contains("text") == false)
            {
                this.ProcessRequestedImage(context, absFilePath);
            }
            else
            {
                this.ProcessRequestedText(context,absFilePath);
            }


        }


        private void ProcessRequestedImage(HttpContext context, string absFilePath)
        {
            bool useCache = true;
            string cacheKey = absFilePath;
            Byte[] imageBytes = new byte[0];

            // Get data
            if (useCache)
            {
                // Check if the cache contains the image.
                Object cachedObject = context.Cache.Get(cacheKey);

                if (cachedObject != null)
                {
                    imageBytes = (Byte[])cachedObject;
                }
            }

            // check is not found in cache
            if (imageBytes.Length == 0)
            {
                imageBytes = this.PullDataAsBinary(context, absFilePath);

                if (useCache)
                {
                    // Store it in the cache (to be expired after 2 hours).
                    context.Cache.Add(cacheKey, imageBytes, null, DateTime.MaxValue, new TimeSpan(2, 0, 0), CacheItemPriority.Normal, null);
                }
            }

            // Send back in Response.
            context.Response.ContentType = "image/jpeg";
            context.Response.Cache.SetCacheability(HttpCacheability.Public);
            context.Response.Cache.SetLastModified(DateTime.Now);
            context.Response.BufferOutput = false;
            context.Response.OutputStream.Write(imageBytes, 0, imageBytes.Length);
            //context.Response.End;
        }

        private void ProcessRequestedText(HttpContext context, string absFilePath)
        {
            bool useCache = false;
            string cacheKey = absFilePath;
            string content = string.Empty;

            // Get data
            if (useCache)
            {
                // Check if the cache contains the image.
                Object cachedObject = context.Cache.Get(cacheKey);

                if (cachedObject != null)
                {
                    content = (string)cachedObject;
                }
            }

            // check is not found in cache
            if (content.Length == 0)
            {
                content = this.PullDataAsText(context, absFilePath);
                content = this.ParseData(content);  // to ensure .ashx paths are converted also

                if (useCache)
                {