packages feed

xstatic-0.2.0: src/XStatic.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

module XStatic (
    -- * xstatic api
    XStaticFile (..),
    xstaticApp,
    xstaticMiddleware,

    -- * helpers
    isGzip,
) where

import Data.Binary.Builder (Builder, fromByteString)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 (pack)
import Data.ByteString.Unsafe qualified as BS (unsafeDrop)
import Data.Map.Strict qualified as Map
import Network.HTTP.Types.Header (ResponseHeaders)
import Network.HTTP.Types.Status qualified as HTTP
import Network.Wai qualified

{- | A static file definition.

 Use the @xstatic-th@ or @file-embed@ library to embed a local file.
-}
data XStaticFile = XStaticFile
    { xfPath :: ByteString
    -- ^ The expected request path.
    , xfContent :: ByteString
    -- ^ The file content.
    , xfETag :: ByteString
    -- ^ The etag header value.
    , xfType :: ByteString
    -- ^ The content type, e.g. \"application\/javascript\" or \"text\/css\".
    }

{- | Create a wai application to serve 'XStaticFile'.

 The \"\/xstatic\/\" request path prefix is ignored.
-}
xstaticApp :: [XStaticFile] -> Network.Wai.Application
xstaticApp xs = \req resp -> do
    let basePath = Network.Wai.rawPathInfo req
        requestPath
            | "/xstatic/" `BS.isPrefixOf` basePath = BS.unsafeDrop 8 basePath
            | otherwise = basePath

    resp $ case Map.lookup requestPath files of
        Just (builder, headers) ->
            let body = case Network.Wai.requestMethod req of
                    "HEAD" -> mempty
                    _ -> builder
             in Network.Wai.responseBuilder HTTP.status200 headers body
        Nothing -> Network.Wai.responseLBS HTTP.status404 mempty mempty
  where
    files :: Map.Map ByteString (Builder, ResponseHeaders)
    files = Map.fromList $ map toItem xs

    toItem :: XStaticFile -> (ByteString, (Builder, ResponseHeaders))
    toItem xf =
        ( xfPath xf
        ,
            ( fromByteString $ xfContent xf
            , (addGzipHeader $ xfContent xf)
                [ ("cache-control", "public, max-age=604800")
                , ("content-length", pack . show . BS.length $ xfContent xf)
                , ("content-type", xfType xf)
                , ("connection", "keep-alive")
                , ("etag", xfETag xf)
                , ("keep-alive", "timeout=5, max=100")
                ]
            )
        )

-- | Serve 'XStaticFile' using 'xstaticApp' when the provided application returns a 404.
xstaticMiddleware :: [XStaticFile] -> Network.Wai.Middleware
xstaticMiddleware xs app req resp = app req handleAppResp
  where
    staticApp = xstaticApp xs
    handleAppResp appResp = case HTTP.statusCode (Network.Wai.responseStatus appResp) of
        404 -> staticApp req resp
        _ -> resp appResp

addGzipHeader :: ByteString -> ResponseHeaders -> ResponseHeaders
addGzipHeader fileContent
    | isGzip fileContent = (("content-encoding", "gzip") :)
    | otherwise = id

isGzip :: ByteString -> Bool
isGzip = BS.isPrefixOf "\x1f\x8b\x08" -- the gzip magic number for deflate