Providing SPA type applications from ZIP files using NancyFX

NancyFX, .NET, English posts

Comments

3 min read

I have a scenario where I have a large SPA application I want to serve using a self-host solution. Basically, it's a full-blown web-application which I want to serve from a (portable) executable without requiring a web server.

NancyFX and it's self-host feature are perfect for this, only I didn't really feel like embedding lots of static files within it. Instead, I wanted to serve those files directly from a satellite ZIP file. This will save me from maintaining the SPA in the host project. In my case, that SPA is provided zipped and not under my control anyway.

To do this, we basically need to tell Nancy every request coming in is to be considered a static file, and delegate that request to a Response object which knows how to find the requested file with the provided ZIP. This is done by overriding ConfigureConventions in the Nancy Bootstrapper:

    protected override void ConfigureConventions(NancyConventions conventions)
    {
        base.ConfigureConventions(conventions);

        conventions.StaticContentsConventions.Clear();
        conventions.StaticContentsConventions.Add((ctx, root) =>
        {
            var reqPath = ctx.Request.Path;

            if (reqPath.Equals("/")) // here you specify your index page
            {
                reqPath = "/index.html";
            }
            
            // optionally fall back to an on-disk file, you can just remove this    
            var fileName = Path.GetFullPath(Path.Combine(root, reqPath));
            if (File.Exists(fileName))
            {
                return new GenericFileResponse(fileName, ctx);
            }
            
            // and here you can get a chance to manipulate the requested path
            // for example, if the SPA in the zip file is contained within a parent folder

            return new FromZipFileResponse(
                ZipFilePath,
                reqPath,
                ctx.Request.Headers);
        });
    }

The custom Response object looks like this - note the built-in support for etags and wire compression:

public class FromZipFileResponse : Response
{
    private readonly bool _disableRequestCompression;

    public FromZipFileResponse (string zipFilePath, string resourcePath, RequestHeaders requestHeaders = null, bool disableRequestCompression = false)
    {
        _disableRequestCompression = disableRequestCompression;

        // Generate the etag for the zip file and use it for optionally returning HTTP Not-Modified
        var zipFileEtag = "zip" + File.GetLastWriteTime(zipFilePath).Ticks.ToString("G");
        if (requestHeaders != null && (requestHeaders.IfMatch.Any(x => x == zipFileEtag) || requestHeaders.IfNoneMatch.Any(x => x == zipFileEtag)))
        {
            StatusCode = HttpStatusCode.NotModified;
            this.WithHeader("ETag", zipFileEtag);
            return;
        }

        var content = GetFileFromZip(zipFilePath, resourcePath);
        if (content != null)
        {
            Contents = content;
            if (_disableRequestCompression == false)
                Headers["Content-Encoding"] = "gzip";
            this.WithHeader("ETag", zipFileEtag);
        }
        else
        {
             StatusCode = HttpStatusCode.NotFound;
             return;
        }
        
        ContentType = MimeTypes.GetMimeType(Path.GetFileName(resourcePath));
        StatusCode = HttpStatusCode.OK;
    }

    private Action<Stream> GetFileFromZip(string zipPath, string docPath)
    {
        var fileStream = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
        var zipFile = new ZipFile(fileStream);
        var zipEntry = zipFile.GetEntry(docPath);

        if (zipEntry == null || zipEntry.IsFile == false)
            return null;

        var data = zipFile.GetInputStream(zipEntry);
        if (data == null) return null;

        return stream =>
               {
                   try
                   {
                       if (_disableRequestCompression == false)
                           stream = new GZipStream(stream, CompressionMode.Compress, leaveOpen: true);

                       data.CopyTo(stream);
                       stream.Flush();
                   }
                   finally
                   {
                       if (_disableRequestCompression == false)
                           stream.Dispose();
                   }
               };
    }
}

This is not something complete or extensible or anything, but it works and works well. You should find it very easy to change and customize, and overall it's probably the best way to serve rich applications that can be run from the browser, leveraging all its power, in a ship-able form and without requiring a web server and complex setups.


Comments are now closed