packages feed

xstatic-0.1.0: src/XStatic.hs

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

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

    -- * file-embed re-export
    embedFile,
) where

import Data.Binary.Builder (Builder, fromByteString)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 (breakEnd, pack)
import Data.ByteString.Char8 qualified as BS8
import Data.FileEmbed (embedFile)
import Data.Map.Strict qualified as Map
import Data.Version (Version, showVersion)
import Network.HTTP.Types.Header (ResponseHeaders)
import Network.HTTP.Types.Status qualified as HTTP
import Network.Wai qualified

-- | A static file definition
data XStaticFile = XStaticFile
    { name :: ByteString
    -- ^ The expected request filename. The name must not have any slash '/'.
    , content :: ByteString
    -- ^ The file content gzip compressed.
    , contentVersion :: Version
    -- ^ The file version for the etag header.
    , contentType :: ByteString
    -- ^ The content type, e.g. `text/javascript` or `text/css`.
    }

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

 The request are served whenever the basename match, ignoring the parent directory or the query string.
-}
xstaticApp :: [XStaticFile] -> Network.Wai.Application
xstaticApp xs = \req resp ->
    let (_, basename) = breakEnd (== '/') (Network.Wai.rawPathInfo req)
     in resp $ case Map.lookup basename 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 =
        ( name xf
        ,
            ( fromByteString $ content xf
            , (addGzipHeader $ content xf)
                [ ("cache-control", "public, max-age=604800")
                , ("content-length", pack . show . BS.length $ content xf)
                , ("content-type", contentType xf)
                , ("connection", "keep-alive")
                , ("etag", versionToEtag $ contentVersion xf)
                , ("keep-alive", "timeout=5, max=100")
                ]
            )
        )

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

versionToEtag :: Version -> ByteString
versionToEtag = BS8.unwords . BS8.split '.' . pack . showVersion