Introduction
When developing custom controls for ASP.NET, it may be necessary to create some client-side script that is used to interact with the custom control. There may also be image files that are used for certain elements of the control, such as buttons or style sheets that set the look of the control. A decision has to be made about how to deploy these resources with the custom control assembly.
The script can be built up using a StringBuilder
or a static string, and inserted directly into the page using RegisterClientScriptBlock
. This is okay for small scripts, but is unwieldy for much larger scripts, especially if they are complex enough to need debugging to work out problems during development. Scripts can also be embedded as resources in the assembly, retrieved at runtime, and again inserted into the page using RegisterClientScriptBlock
. This is better than the StringBuilder
approach for large scripts, but it is still inserted into the page and is rendered in its entirety, every time the page is requested. The more script code you have or the more controls on the page that render supporting script code, the larger the page gets. The script also cannot be cached on the client to save time on subsequent requests.
The scripts can be distributed as separate files along with the assembly. This solves the problem of the code being rendered in the page on each request and it can be cached on the client. However, it may complicate distribution of the custom control. It is no longer a simple XCOPY deployment as now scripts have to be installed along with the assembly. A number of factors such as whether it is a production or development server, whether or not the application is using SSL, and how the end-user's applications are set up, can affect where the scripts go, and you may end up with multiple copies in several locations. Versioning issues may also come into play if the scripts are modified in future releases of the control.
To solve these issues, I developed a class that implements the System.Web.IHttpHandler
interface and acts as a resource server of sorts. The idea was inspired by examples I saw that showed how to render dynamic images using an ASPX page as the source of the image
tag. The concept is basically the same for the resource server handler. You embed the resources in the control assembly and then add a simple class to your custom control assembly that implements the IHttpHandler
interface. A section is added to the application's Web.Config file to direct resource requests to the handler class. The handler class uses parameters in the query string to determine the content type and sends back the appropriate resource such as a script, an image, a style sheet, or any other type of data that you need.
By having the resources embedded in the assembly and serving them as needed, there is as little code rendered in the page as possible by the controls. The resource handler responses can also be cached on the client, so performance can be improved as less information is sent to the client in subsequent page requests that utilize the same resources. This is most beneficial for users with slow dial-up connections, especially on forms that utilize controls with auto-postback enabled. The resources do not have to be deployed separately along with the assembly either, thus solving the problem of where to install the resources, as well as any issues involving versioning. We are back to a simple XCOPY deployment again.
The use of a resource server handler is not restricted to custom controls. It also adds the ability to do such things as request dynamic content, such as XML, using client-side script. For example, a request could be made to retrieve the results of a database query as XML using client-side script. The results could be used to populate a control or a popup window with information when it is needed rather than sending everything along with the page when first loaded. The following sections describe how to setup and utilize the resource server handler class.
A word about ASP.NET 2.0
The following will allow you to embed and serve resources in ASP.NET 1.1 applications as well as ASP.NET 2.0 applications. However, with ASP.NET 2.0 the ability to serve embedded web resources is a built-in feature and is simpler to implement. It makes use of embedded resources as described in here but utilizes attributes to define them along with a built-in Page.ClientScript
method to render a link to them to the client. It does not require you to write a handler and does not require any entries in the Web.config file to define the handler or to allow anonymous access to them. Gary Dryden has already written a good article on how this works so I will just refer you to it rather than repeating what it has to say (WebResource ASP.NET 2.0 Explained[^]).
Add resources to your project
To keep things organized, store the resources in separate folders grouped by type (Scripts for script files, Images for image files, etc.). To create a new folder in the project, right click on the project name, select Add..., select New Folder, and enter the folder name. Add a new resource to the folder by right clicking on it, and selecting Add... and then Add New Item... to create a new item, or Add Existing Item... if you copied an existing file to the new folder. Once added to the project folder, right click on the file and select Properties. Change the Build Action property from Content to Embedded Resource. This step is most important as it indicates that you want the file to be embedded as a resource in the compiled assembly.
Add the ResSrvHandler class to your project
Add the ResSrvHandler.cs source file to your control's project and modify it as follows. TODO: comments have been added to help you find the sections that need modification.
Modify the namespace so that it matches the one for your custom control:
namespace ResServerTest.Web.Controls
{
Modify the cResSrvHandlerPageName
constant so that it matches the name you will use in the application's Web.Config file to direct resource requests to the class. I have chosen to use the custom control namespace with an .aspx extension. This keeps it unique and guarantees that it won't conflict with something in the end-user's application:
public const string cResSrvHandlerPageName =
"ResServerTest.Web.Controls.aspx";
Modify the cImageResPath
and cScriptResPath
constants to point to your script and image paths. Add additional constants for other resource type paths as needed. The names of the embedded resources in the assembly are created by using the default namespace of the project plus the folder path to the resource. The default namespace is usually the same as the assembly name but you can modify it by right clicking on the project name, selecting Properties, and changing the Default Namespace option in the General section of the Common Properties entry. For the demo, the default namespace has been changed to match the namespace of the control, ResServerTest.Web.Controls
, and the resource paths are Images and Scripts. As such, the constants are defined as shown below. The trailing "." should also be included. The resource name will be appended to the appropriate constant when loading it from the assembly.
Note that if you are using VB.NET, the default behavior of the compiler differs from the C# compiler. It will not append the default namespace to the front of the resource filename unless you explicitly include the command line option to tell it to do that. As such, for VB.NET projects, you can omit the path constants or set them to empty strings:
private const string cImageResPath =
"ResServerTest.Web.Controls.Images.";
private const string cScriptResPath =
"ResServerTest.Web.Controls.Scripts.";
The ResourceUrl
method can be called to format the URL used to retrieve an embedded resource from an assembly. The first version will retrieve the named resource from the assembly that contains the class. Simply pass it the name of the resource and it returns a URL that points to the resource.
The second version of the method can be used to extract embedded resources from assemblies other than the one containing the resource server class. Pass it the name of the assembly that contains the resource (without a path or extension, System.Web
for example), the name of the resource handler that can retrieve it (i.e., the class defined in the cResSrvHandlerPageName
constant), and the name of the resource to retrieve. When using this version, the name of the resource will be matched to the first resource that ends with the specified name. This allows you to skip the path name if you do not know it or extract resources from VB.NET assemblies which do not store the path:
public static string ResourceUrl(string strResourceName,
bool bCacheResource)
{
return String.Format("{0}?Res={1}{2}", cResSrvHandlerPageName,
strResourceName, (bCacheResource) ? "" : "&NoCache=1");
}
public static string ResourceUrl(string strAssemblyName,
string strResourceHandlerName, string strResourceName,
bool bCacheResource)
{
return String.Format("{0}?Assembly={1}&Res={2}{3}",
strResourceHandlerName,
HttpContext.Current.Server.UrlEncode(strAssemblyName),
strResourceName, (bCacheResource) ? "" : "&NoCache=1");
}
The IHttpHandler.IsReusable
property is implemented to indicate that the object instance can be reused for other requests. The IHttpHandler.ProcessRequest
method is implemented to do all of the work. The first step is to determine the requested resource's name and its type. I use the filename's extension to determine the type. The code assumes that the query string parameter is called Res
. Adjust this if you choose a different parameter name. Likewise, you can modify the code to determine the resource name and type in any number of ways depending on your needs:
public void ProcessRequest(HttpContext context)
{
Assembly asm;
StreamReader sr = null;
Stream s = null;
string strResName, strType;
byte[] byImage;
int nLen;
bool bUseInternalPath = true;
strResName = context.Request.QueryString["Res"];
strType = strResName.Substring(strResName.LastIndexOf(
'.') + 1).ToLower();
The next step is to clear any current response and set up the caching options. If the NoCache
query string parameter has not been specified, the class sets the necessary page caching options in the context.Response.Cache
object. If it has been specified, the options are set such that the response will never be cached. The class defaults to having the response cached for one day. Adjust this as necessary for your controls. The response is set to vary caching by parameter name. The default class only has one parameter called Res
. If you have additional parameters, be sure to add them as additional VaryByParams
entries:
context.Response.Clear();
if(context.Request.QueryString["NoCache"] == null)
{
context.Response.Cache.SetExpires(DateTime.Now.AddDays(1));
context.Response.Cache.SetCacheability(HttpCacheability.Public);
context.Response.Cache.SetValidUntilExpires(false);
context.Response.Cache.VaryByParams["Res"] = true;
}
else
{
context.Response.Cache.SetExpires(DateTime.Now.AddDays(-1));
context.Response.Cache.SetCacheability(HttpCacheability.NoCache);
}
The next section checks to see if the resource resides in another assembly. If the Assembly
query string option has been omitted, it assumes the resource is in the same assembly as the class. If specified, it looks for the named assembly and, if found, searches for the resource within its manifest. When loading from a different assembly, the internal class path names are ignored and the name matched during the search is used instead:
if(context.Request.QueryString["Assembly"] == null)
asm = Assembly.GetExecutingAssembly();
else
{
Assembly[] asmList =
AppDomain.CurrentDomain.GetAssemblies();
string strSearchName =
context.Request.QueryString["Assembly"];
foreach(Assembly a in asmList)
if(a.GetName().Name == strSearchName)
{
asm = a;
break;
}
if(asm == null)
throw new ArgumentOutOfRangeException("Assembly",
strSearchName, "Assembly not found");
foreach(string strResource in asm.GetManifestResourceNames())
if(strResource.EndsWith(strResName))
{
strResName = strResource;
bUseInternalPath = false;
break;
}
}
As given, the class can serve up various image and script types, some styles for the demo, plus an additional XML file to demonstrate the NoCache
option. A simple switch
statement is used to determine what type to send back. The context.Response.ContentType
property is set accordingly, the resource is retrieved, and it is then written to the response stream. You can expand or reduce the code to suit your needs:
switch(strType)
{
case "gif":
case "jpg":
case "jpeg":
case "bmp":
case "png":
case "tif":
case "tiff":
if(strType == "jpg")
strType = "jpeg";
else
if(strType == "png")
strType = "x-png";
else
if(strType == "tif")
strType = "tiff";
context.Response.ContentType =
"image/" + strType;
if(bUseInternalPath == true)
strResName = cImageResPath + strResName;
s = asm.GetManifestResourceStream(strResName);
nLen = Convert.ToInt32(s.Length);
byImage = new Byte[nLen];
s.Read(byImage, 0, nLen);
context.Response.OutputStream.Write(
byImage, 0, nLen);
break;
case "js":
case "vb":
case "vbs":
if(strType == "js")
context.Response.ContentType =
"text/javascript";
else
context.Response.ContentType =
"text/vbscript";
if(bUseInternalPath == true)
strResName = cScriptResPath + strResName;
sr = new StreamReader(
asm.GetManifestResourceStream(strResName));
context.Response.Write(sr.ReadToEnd());
break;
case "css":
context.Response.ContentType = "text/css";
if(bUseInternalPath == true)
context.Response.Write(".Style1 { font-weight: bold; " +
"color: #dc143c; font-style: italic; " +
"text-decoration: underline; }\n" +
".Style2 { font-weight: bold; color: navy; " +
"text-decoration: underline; }\n");
else
{
sr = new StreamReader(
asm.GetManifestResourceStream(strResName));
context.Response.Write(sr.ReadToEnd());
}
break;
case "htm":
case "html":
context.Response.ContentType = "text/html";
sr = new StreamReader(
asm.GetManifestResourceStream(strResName));
context.Response.Write(sr.ReadToEnd());
break;
case "xml":
context.Response.ContentType = "text/xml";
sr = new StreamReader(
asm.GetManifestResourceStream(
"ResServerTest.Web.Controls." + strResName));
string strXML = sr.ReadToEnd();
context.Response.Write(strXML.Replace("DATETIME",
DateTime.Now.ToString()));
break;
default:
throw new Exception("Unknown resource type");
}
For simple text-based resources such as scripts, the StreamReader.ReadToEnd
method can be used to retrieve the resource. For binary resources such as images, you must allocate an array and use StreamReader.Read
to load the image into the array. Once loaded, you can write the array out to the client as shown above.
If an unknown resource type is requested or if it cannot be loaded from the assembly, an exception is thrown. For script resource types, the exception handler will convert the response to the appropriate type and send back a message box or alert so that the exception is displayed when the page loads. This will give you a chance to see what failed during development. For an XML resource, the exception handler will send back an XML response containing nodes with the resource name and the error description. For all other resource types, nothing is returned. Images will display a broken image placeholder, which serves as an indication that you may have done something wrong:
catch(Exception excp)
{
XmlDocument xml;
XmlNode node, element;
string strMsg = excp.Message.Replace("\r\n", " ");
context.Response.Clear();
context.Response.Cache.SetExpires(
DateTime.Now.AddDays(-1));
context.Response.Cache.SetCacheability(
HttpCacheability.NoCache);
switch(strType)
{
case "js":
context.Response.ContentType = "text/javascript";
context.Response.Write(
"alert(\"Could not load resource '" +
strResName + "': " + strMsg + "\");");
break;
case "vb":
case "vbs":
context.Response.ContentType = "text/vbscript";
context.Response.Write(
"MsgBox \"Could not load resource '" +
strResName + "': " + strMsg + "\"");
break;
case "xml":
xml = new XmlDocument();
node = xml.CreateElement("ResourceError");
element = xml.CreateElement("Resource");
element.InnerText = "Could not load resource: " +
strResName;
node.AppendChild(element);
element = xml.CreateElement("Exception");
element.InnerText = strMsg;
node.AppendChild(element);
xml.AppendChild(node);
context.Response.Write(xml.InnerXml);
break;
}
}
finally
{
if(sr != null)
sr.Close();
if(s != null)
s.Close();
}
Using the resource server handler in your control
Using the resource server handler in the custom control is very simple. Just add code to your class to render the attributes, script tags, or other resource types such as images that utilize the resource server page name. This is done by calling the ResSrvHandler.ResourceUrl
method with the name of the resource and a Boolean flag indicating whether or not to cache it on the client. The demo control contains several examples.
img = new HtmlImage();
img.Src = ResSrvHandler.ResourceUrl("FitHeight.bmp", true);
img.Attributes["onclick"] = "javascript: FitToHeight()";
this.Controls.Add(img);
this.Page.RegisterStartupScript("Demo_Startup",
"<script type='text/javascript' src='" +
ResSrvHandler.ResourceUrl("DemoCustomControl.js", true) +
"'></script>");
this.Page.RegisterScriptBlock("Demo_Styles",
"<link rel='stylesheet' type='text/css' href='" +
ResSrvHandler.ResourceUrl("Styles.css") + "'>\n");
As noted earlier, the lack of the NoCache
query string option will cause the resources to be cached on the client. To turn off caching for a resource, simply specify false
for the cache parameter of the ResourceUrl
method, or add the NoCache
parameter to the query string if hand-coding the URL. The demo ASPX page contains an example that retrieves an XML document from the control assembly. It uses the no caching option so that it displays the current time on the server every time the XML resource is requested. It also contains a couple of examples that retrieve resources from assemblies other than the custom control's assembly.
<script type='text/javascript'>
function funShowXML()
{
window.open(
'ResServerTest.Web.Controls.aspx?Res=Demo.xml&NoCache=1',
null,
'menubar=no,personalbar=no,resizable=yes,' +
'scrollbars=yes,status=no,' +
'toolbar=no,screenX=50,screenY=50,' +
'height=400,width=800').focus()
}
</script>
Using the control and the resource server handler in an application
In the application's project, add a reference to your custom control's assembly and add your custom control to the application's forms in the normal fashion. To use the resource server handler, add an entry in the <system.web>
section of your application's Web.Config file like the following:
<!---->
<httpHandlers>
<add verb="*" path="ResServerTest.Web.Controls.aspx"
type="ResServerTest.Web.Controls.ResSrvHandler,
ResServerTest.Web.Controls"/>
</httpHandlers>
Modify the path
attribute so that it matches the ResSrvHandler.cResSrvHandlerPageName
constant. Modify the type
attribute to reference your resource server handler's class name (including its namespace) followed by a comma and then the name of the assembly. This entry causes any requests containing the page name specified in the path
attribute, regardless of the folder, to get mapped to your resource handler class.
Allowing anonymous access to resources when using forms-based authentication
When using forms-based authentication to secure an entire application, access to the resources is prevented on the logon page because the above HTTP handler uses an ASPX page name to route the requests for resources to the handler. As such, it is treated like any other request for a normal page, and instead of returning the resource, ASP.NET redirects the request to the login page as well. To prevent this and allow anonymous access to the resource server handler from the logon page, the following section should be added to the <configuration>
section of your Web.config file:
<!---->
<location path="ResServerTest.Web.Controls.aspx">
<system.web>
<authorization>
<allow users="*"/>
</authorization>
</system.web>
</location>
This allows all users to access the resources without authentication, thus allowing the classes and controls in the assembly that utilize the resource server handler to be used on the logon web form. Just modify the page name in the path
attribute of the location
tag to match the one used in the HTTP handler section.
Common errors and problems
The most common errors when using the resource server handler are misspelling the resource name and forgetting to change the Build Action property to Embedded Resource. In both cases, the error "Value cannot be null. Parameter name: stream
" is returned for script and XML resources. Image resources just won't display anything. The demo project contains examples of these errors.
Another common error is misspelling the ASPX page name when referencing it in the Web.Config file or in Web Forms when retrieving the dynamic content. In these cases, you will get broken links or "resource not found" errors. If you misspell the class or assembly name in the Web.Config file, the application will fail to start, and you will get an error telling you that it cannot find the specified type or assembly. The type or assembly shown in the error message will be the incorrect name for the resource handler class or its assembly. Correcting the names will resolve the errors.
A problem that can occur during development is making modifications to the resources and then not seeing those changes reflected when testing the control. The reason for this is that embedded resources do not create a build dependency. As such, modifying them does not cause a rebuild of the assembly. When making such changes, you just have to remember to always force a rebuild of the assembly so that it embeds the updated resources. You may also have to force a refresh of the page to get it to download the updated resource (Ctrl+F5 in Internet Explorer for example).
You can test the retrieval of a resource and view what gets returned by opening the browser and entering the URL to the resource. For text-based resources, prefix the URL with "view-source:". For example:
view-source:http://localhost/ResSrvTest/ResServerTest.Web.
Controls.aspx?Res=DemoCustomControl.js
This would retrieve the DemoCustomControl.js file and display it in a Notepad window.
The demo
To try out the demo application and custom control, create a virtual directory in IIS called ResSrvTest and point it at the DotNet\Web\ReServerTest\ResSrvTest folder. The startup page is WebForm1.aspx. The demo project is set up to compile and run on a development machine that has Visual Studio .NET 2003 and IIS running on it. If you are using a remote server, you will need to set up the virtual directory, build the demo custom control separately, and copy it and the demo application files to the server location.
Conclusion
I have used this method in my custom controls that utilize client-side script and image files, and have found it to be quite useful. Development of the controls involving client-side script has been made easier as has the deployment of the control assemblies.
Revision history