wai-middleware-brotli (empty) → 0.1.0.0
raw patch · 7 files changed
+725/−0 lines, 7 filesdep +basedep +binarydep +bytestringsetup-changed
Dependencies added: base, binary, bytestring, directory, filepath, hs-brotli, http-types, mtl, tasty, tasty-hspec, tasty-hunit, unix, wai, wai-app-static, wai-extra, wai-middleware-brotli, warp
Files
- LICENSE +30/−0
- README.md +0/−0
- Setup.hs +2/−0
- app/Main.hs +24/−0
- src/Network/Wai/Middleware/Brotli.hs +400/−0
- test/Spec.hs +207/−0
- wai-middleware-brotli.cabal +62/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Ian Duncan (c) 2017++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Ian Duncan nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE OverloadedStrings #-}+module Main where++import Control.Concurrent+import Network.HTTP.Types.Status+import Network.Wai+import qualified Network.Wai.Middleware.Brotli as B+import Network.Wai.Handler.Warp+import Network.Wai.Application.Static+import Network.Wai.Middleware.Gzip++main :: IO ()+main = do+ let settings =+ B.defaultSettings+ {B.brotliFilesBehavior = B.BrotliPreCompressed (B.BrotliCacheFolder "."), B.brotliMinimumSize = 5}+ -- app = staticApp $ defaultFileServerSettings "."+ app = \req respond -> respond $ responseStream ok200 [("Content-Type", "text/wow")] $ \send flush -> do+ send "Hello"+ flush+ threadDelay 10000000+ send "World"++ runEnv 3000 (gzip def {- TODO replace gzip with zopfli -} $ B.brotli settings {- $ sdch sdchSettings -} $ app)
+ src/Network/Wai/Middleware/Brotli.hs view
@@ -0,0 +1,400 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+---------------------------------------------------------+-- |+-- Module : Network.Wai.Middleware.Brotli+-- Copyright : Ian Duncan+-- License : BSD3+--+-- Maintainer : Ian Duncan <ian@iankduncan.com>+-- Stability : Unstable+-- Portability : portable+--+-- Automatic brotli compression of responses.+--+-- If you are using this middleware with wai-extra's @gzip@ middleware,+-- it is important that @brotli@ wraps your application before gzip does+-- or your responses will be compressed by both, which is not beneficial.+--+-- Correct:+--+-- > gzip def . brotli defaultSettings+--+-- Incorrect:+--+-- > brotli defaultSettings . gzip def+---------------------------------------------------------+module Network.Wai.Middleware.Brotli+ ( brotli+ , defaultSettings+ , BrotliSettings(..)+ , defaultShouldCompress+ , BrotliFiles(..)+ ) where++import Codec.Compression.Brotli+import Control.Exception+import Control.Monad+import Data.Binary.Builder (toLazyByteString, fromLazyByteString, fromByteString, Builder)+import qualified Data.ByteString.Char8 as B+import qualified Data.ByteString.Lazy as L+import Data.IORef+import Data.Maybe+import Data.Monoid+import Network.HTTP.Types.Header+import Network.HTTP.Types.Status+import Network.Wai+import Network.Wai.Internal+import System.Directory+import System.FilePath+import System.IO+import System.Posix++data BrotliFiles+ = BrotliIgnore -- ^ Do not compress file responses+ | BrotliCompress+ -- ^ Compress files on the fly. Note that this may counteract zero-copy+ -- response optimizations on some platforms.+ | BrotliCacheFolder FilePath+ -- ^ Compress files, caching them in the specified directory. Note that+ -- changes to the original files will not invalidate existing cached files,+ -- so it is important to clear the cache directory appropriately if the+ -- original file has changed+ | BrotliPreCompressed BrotliFiles+ -- ^ Look for the original file, only with the ".br" extension appended to it. Will fall back+ -- to the provided file setting if the file doesn't exist.+ deriving (Read, Eq, Show)++data BrotliSettings = BrotliSettings+ { brotliCompressionSettings :: Request -> Response -> CompressionSettings+ , brotliMinimumSize :: Int+ , brotliFilesBehavior :: BrotliFiles+ , brotliShouldCompress :: BrotliSettings -> Request -> Response -> Bool+ , brotliMimePrefixes :: [B.ByteString]+ , brotliMimeSuffixes :: [B.ByteString]+ }++-- | It is recommended that you combine your custom logic around+-- deciding to compress with this function, such as:+--+-- > \settings req resp -> defaultShouldCompress settings req resp && customPredicate+defaultShouldCompress :: BrotliSettings -> Request -> Response -> Bool+defaultShouldCompress settings req resp =+ let (mclb, hs) = pluckHeader hContentLength (responseHeaders resp)+ (mctb, hs') = pluckHeader hContentType hs+ in case mctb of+ Nothing -> False+ Just ctb ->+ case mclb of+ Nothing -> checkMime settings ctb+ Just clb ->+ (fromMaybe True $ do+ (x, rest) <- B.readInt clb+ return $+ if x >= brotliMinimumSize settings+ then True+ else False) &&+ checkMime settings ctb++checkMime :: BrotliSettings -> B.ByteString -> Bool+checkMime settings ctb =+ any (\b -> b `B.isPrefixOf` ctb) (brotliMimePrefixes settings) ||+ any (\b -> b `B.isSuffixOf` ctb) (brotliMimeSuffixes settings)++-- A sane set of starting defaults. Tuned to use faster / better compression+-- than GZip defaults for non-file responses. Compresses most common text-based formats,+-- skips compression on responses with known Content-Lengths that already fit+-- within one TCP packet.+--+-- Note that this configuration does *not* compress file responses. Customizing+-- @brotliFilesBehavior@ will use brotli's maximum compression quality by default, which is+-- quite slow (albeit achieves very good compression ratios). It is highly recommended that+-- you have an appropriate caching strategy to avoid compression of files on each request.+defaultSettings :: BrotliSettings+defaultSettings =+ BrotliSettings+ (\_ resp ->+ case resp of+ ResponseFile _ _ _ _ ->+ defaultCompressionSettings {compressionQuality = 11}+ _ -> defaultCompressionSettings {compressionQuality = 4})+ 860+ BrotliIgnore+ defaultShouldCompress+ [ "text/"+ , "application/json"+ , "application/javascript"+ , "application/x-javascript"+ , "application/ecmascript"+ , "application/xml"+ , "application/x-font-ttf"+ , "image/x-icon"+ , "image/vnd.microsoft.icon"+ , "application/vnd.ms-fontobject"+ , "application/x-font-opentype"+ , "application/x-font-truetype"+ , "font/eot"+ , "font/otf"+ , "font/ttf"+ , "font/opentype"+ ]+ ["+json", "+xml"]+++pluckHeader :: HeaderName -> [Header] -> (Maybe B.ByteString, [Header])+pluckHeader hName = foldr go (Nothing, [])+ where+ go h@(hKey, hVal) (mh, hs) = if hKey == hName+ then (Just hVal, hs)+ else (mh, h:hs)++splitCommas :: B.ByteString -> [B.ByteString]+splitCommas = B.split ',' . B.filter (/= ' ')++-- SDCH + brotli may be better together than separate, but gzip and brotli together aren't useful+canUseReqEncoding :: [B.ByteString] -> (Bool, [B.ByteString])+canUseReqEncoding = foldr go (False, [])+ where+ go :: B.ByteString -> (Bool, [B.ByteString]) -> (Bool, [B.ByteString])+ go bs (seenBr, encs) = if bs == "br"+ then (True, encs)+ else if seenBr && bs == "gzip"+ then (seenBr, encs)+ else (seenBr, bs:encs)++decodeRequestBody :: Request -> IO Request+decodeRequestBody req = do+ let (encoding, hs) = pluckHeader "Content-Encoding" $ requestHeaders req+ case encoding of+ Nothing -> return req+ Just enc ->+ let encs = splitCommas enc+ getBs = requestBody req+ in case encs of+ ("br":encs) -> do+ bodyDecompressor <- decomp getBs+ return $+ req+ { requestHeaders =+ ("Content-Encoding", B.intercalate ", " encs) : hs+ , requestBody = bodyDecompressor+ }+ _ -> return req+ where+ decomp :: IO B.ByteString -> IO (IO B.ByteString)+ decomp popper = do+ dc <- decompressor+ ref <- newIORef dc+ return $ step ref+ where+ step ref = do+ c <- readIORef ref+ case c of+ Done -> return ""+ Consume f -> do+ bs <- popper+ r <- f bs+ writeIORef ref r+ step ref+ Produce bs act -> do+ r <- act+ writeIORef ref r+ return bs+ Error -> error "Brotli stream decoding error in request body"++-- | Use brotli to decompress request bodies & compress response bodies.+--+-- Analyzes the Accept-Encoding and Content-Type headers to determine if+-- brotli is supported.+brotli :: BrotliSettings -> Middleware+brotli settings app req sendResponse = do+ decodedReq <-+ decodeRequestBody $+ (reqWithoutBrotliAcceptEnc+ { requestHeaders =+ (hAcceptEncoding, B.intercalate ", " remainingReqEncs) :+ requestHeaders reqWithoutBrotliAcceptEnc+ })+ app decodedReq $ \res ->+ if isBrotliResp+ then wrapResponse settings req res sendResponse+ else sendResponse res+ where+ (mEncs, reqWithoutBrotliAcceptEnc) =+ case pluckHeader hAcceptEncoding $ requestHeaders req of+ (ms, hs) -> (ms, req {requestHeaders = hs})+ (isBrotliResp, remainingReqEncs) =+ case mEncs of+ Nothing -> (False, [])+ Just ebs -> canUseReqEncoding $ splitCommas ebs++fixHeaders :: [Header] -> [Header]+fixHeaders = filter (\(k, _) -> k /= hContentLength)++wrapResponse :: BrotliSettings -> Request -> Response -> (Response -> IO a) -> IO a+wrapResponse settings req res sendResponse =+ let shouldCompress = (brotliShouldCompress settings) settings req+ in if shouldCompress res+ then case res of+ ResponseFile status hs fp bp ->+ compressedFileResponse+ settings+ addBrotliToContentEnc+ req+ status+ hs+ fp+ bp+ sendResponse++ ResponseBuilder status hs b -> do+ let compressedResp =+ compressWith+ (brotliCompressionSettings settings req res)+ (toLazyByteString b)+ sendResponse $+ responseLBS status (addBrotliToContentEnc hs) compressedResp++ ResponseStream status hs f ->+ sendResponse $+ ResponseStream status (addBrotliToContentEnc hs) $ \send flush -> do+ c <- compressor (brotliCompressionSettings settings req res)+ ref <- newIORef c+ f (compressSend send flush ref) (compressFlush send flush ref)+ c' <- readIORef ref+ case c' of+ Consume consumer ->+ consumer (Chunk B.empty) >>= finishStreaming send+ Produce _ _ -> error "Shouldn't be producing right now"+ Error -> error "Shouldn't be in an error state right now"+ Done -> return ()++ ResponseRaw act fallback -> sendResponse $ responseRaw act fallback++ else sendResponse res+ where+ addBrotliToContentEnc hs =+ let (respEncs, hsWithoutBrotliEnc) = pluckHeader hContentEncoding hs+ in fixHeaders $+ ((hContentEncoding, maybe "br" (<> ", br") respEncs) :+ hsWithoutBrotliEnc)+ finishStreaming send c =+ case c of+ Consume _ -> error "Shouldn't be consuming right now"+ Produce b next ->+ send (fromByteString b) >> next >>= finishStreaming send+ Error -> error "Encountered error while finishing stream"+ Done -> return ()++compressSend :: (Builder -> IO ()) -> IO () -> IORef (BrotliStream Chunk) -> Builder -> IO ()+compressSend innerSend innerFlush st bs = do+ let steps = L.toChunks $ toLazyByteString bs++ action <- readIORef st+ action' <- foldM step action steps+ writeIORef st action'++ where+ step c bs = case c of+ Produce b n -> do+ innerSend $ fromByteString b+ r <- n+ step r bs+ Consume consumer -> consumer $ Chunk bs+ Error -> error "Streaming send of response body failed in brotli compression phase"+ Done -> error "Should not be done compressing while still sending data. Did you send an empty bytestring?"++performFlush :: (Builder -> IO ()) -> IO () -> BrotliStream Chunk -> IO (BrotliStream Chunk)+performFlush innerSend innerFlush c = case c of+ Consume consumer -> consumer Flush >>= performFlush'+ _ -> error "Shouldn't be flushing value when not in consumption state"+ where+ performFlush' c = case c of+ Produce b n -> do+ innerSend $ fromByteString b+ r <- n+ performFlush' r+ _ -> innerFlush >> pure c++compressFlush :: (Builder -> IO ()) -> IO () -> IORef (BrotliStream Chunk) -> IO ()+compressFlush innerSend innerFlush ref = do+ action <- readIORef ref+ r <- performFlush innerSend innerFlush action+ writeIORef ref r++compressedFileResponse ::+ BrotliSettings+ -> (ResponseHeaders -> ResponseHeaders)+ -> Request+ -> Status+ -> ResponseHeaders+ -> FilePath+ -> Maybe FilePart+ -> (Response -> IO a)+ -> IO a+compressedFileResponse settings addBrotliHeaders req status hs p mfp sendResponse =+ case mfp of+ Nothing ->+ case brotliFilesBehavior settings of+ BrotliIgnore -> sendResponse $ responseFile status hs p Nothing+ BrotliCompress -> do+ size <- fileSize <$> getFileStatus p+ if fromIntegral size >= brotliMinimumSize settings+ then do+ let (status', hs', f) =+ responseToStream $ responseFile status hs p Nothing+ f $ \body ->+ wrapResponse+ settings+ req+ (responseStream status' hs' body)+ sendResponse+ else sendResponse $ responseFile status hs p Nothing+ BrotliCacheFolder cachePath+ -- TODO check file size here maybe+ -> do+ createDirectoryIfMissing True cachePath+ let normalized = normalizeOriginalFile p <.> "br"+ adjustedFile = (cachePath </> normalizeOriginalFile p <.> "br")+ exists <- doesFileExist adjustedFile+ when (not exists) $ do+ (tmpPath, h) <- openBinaryTempFile cachePath ("XXXXX" ++ normalized)+ (flip onException) (removeFile tmpPath) $ do+ original <- L.readFile p+ L.hPut h $ compressWith (brotliCompressionSettings settings req (responseFile status hs p mfp)) original + hClose h+ rename tmpPath adjustedFile+ sendResponse $+ responseFile status (addBrotliHeaders hs) adjustedFile Nothing+ BrotliPreCompressed fallback -> do+ let modifiedPath = p <.> "br"+ exists <- doesFileExist modifiedPath+ if exists+ then sendResponse $+ responseFile status (addBrotliHeaders hs) modifiedPath Nothing+ else compressedFileResponse+ (settings {brotliFilesBehavior = fallback})+ addBrotliHeaders+ req+ status+ hs+ p+ mfp+ sendResponse+ _ -> do+ let (status', hs', f) = responseToStream $ responseFile status hs p mfp+ f $ \body -> do+ wrapResponse settings req (responseStream status' hs' body) sendResponse++normalizeOriginalFile :: FilePath -> FilePath+normalizeOriginalFile file = map safe file+ where+ safe c+ | 'A' <= c && c <= 'Z' = c+ | 'a' <= c && c <= 'z' = c+ | '0' <= c && c <= '9' = c+ safe '-' = '-'+ safe '_' = '_'+ safe '.' = '.'+ safe _ = '_'+
+ test/Spec.hs view
@@ -0,0 +1,207 @@+{-# LANGUAGE OverloadedStrings #-}+import Network.HTTP.Types+import Codec.Compression.Brotli (decompress, compress)+import Control.Monad.Trans+import Control.Concurrent.MVar+import qualified Data.ByteString.Char8 as B+import qualified Data.ByteString.Lazy as L+import qualified Data.ByteString.Builder as Builder+import Data.IORef+import Data.Monoid+import Network.Wai+import Network.Wai.Middleware.Brotli+import Network.Wai.Internal+import Network.Wai.Test+import Test.Tasty+import Test.Tasty.HUnit+import Test.Tasty.Hspec+import System.IO++main :: IO ()+main = do+ putStrLn ""++ bod <- L.readFile "/usr/share/dict/words" -- "Hello World 000000000000000000000000000000"+ let app req respond = respond $ responseLBS status200 [("Content-Type", "text/whatever")] bod++ specs <- testSpecs $ parallel $ do++ it "Encodes text" $ do+ -- app :: Application+ putStr "Encoding text: "+ var <- newEmptyMVar+ (brotli defaultSettings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b -> do+ assert (decompress (Builder.toLazyByteString b) == bod)+ _ -> error "Not what we expected"++ it "Does not encode if brotli not in Accept-Encoding header" $ do+ var <- newEmptyMVar+ (brotli defaultSettings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "gzip"), ("Content-Type", "text/whatever")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b -> do+ assert (Builder.toLazyByteString b == bod)+ _ -> error "Not what we expected"++ it "Decode request body if it's encoded" $ do+ var <- newEmptyMVar+ let reqWithBody =+ defaultRequest+ { requestMethod = "POST"+ , requestHeaders = [("Content-Encoding", "br")]+ , requestBody =+ return $ L.toStrict $ compress ("Hello World" :: L.ByteString)+ }+ (brotli defaultSettings $ \req resp -> do+ reqBod <- requestBody req+ resp $ responseLBS status200 [] (L.fromStrict reqBod))+ reqWithBody+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b ->+ assert (Builder.toLazyByteString b == "Hello World")+ _ -> error "Not what we expected"++ it "Streaming response body encoding" $ do+ var <- newEmptyMVar+ (brotli defaultSettings $ \req respond -> do+ respond $ responseStream status200 [("Content-Type", "text/whatever")] $ \send flush -> do+ send "Hello world\n"+ flush+ send "Goodbye world"+ )+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ r <- newIORef mempty+ case resp of+ ResponseStream stats hs f -> do+ f (\b -> modifyIORef r (<> b)) (return ())+ b <- readIORef r+ assert (decompress (Builder.toLazyByteString b) == "Hello world\nGoodbye world")+ _ -> error "Not what we expected"++ it "Don't encode non-textual content" $ do+ let app req respond = respond $ responseLBS status200 [("Content-Type", "application/binary")] bod+ var <- newEmptyMVar+ (brotli defaultSettings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b -> do+ assert (Builder.toLazyByteString b == bod)+ _ -> error "Not what we expected"++ it "Don't encode if content length is too low" $ do+ let app req respond = respond $ responseLBS status200 [("Content-Type", "application/json"), ("Content-Length", "2")] "{}"+ var <- newEmptyMVar+ (brotli defaultSettings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b -> do+ assert (Builder.toLazyByteString b == "{}")+ _ -> error "Not what we expected"++ it "Do encode if content length is long enough" $ do+ let app req respond = respond $ responseLBS status200 [("Content-Type", "text/whatever"), ("Content-Length", B.pack $ show $ L.length bod)] bod+ var <- newEmptyMVar+ (brotli defaultSettings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseBuilder stats hs b -> do+ assert (decompress (Builder.toLazyByteString b) == bod)+ _ -> error "Not what we expected"++ it "Performs on-the-fly compression of files if set to compress or requesting appropriate content range size" onTheFlyFileCompressTest+ it "Serves precomputed compressed files if appropriate & they exist" precompressedTest++ defaultMain $ testGroup "Tests" specs+++ {-++ putStrLn ""++ putStrLn ""++ putStr ": "+ putStrLn ""++ putStrLn ""++ putStrLn "Compress fallback response for responseRaw if appropriate: Pending"+ putStrLn "Don't compress files if set to ignore: Pending"+ onTheFlyFileCompressTest+ precompressedTest+ putStrLn "Create cached file versions in given folder if appropriate: Pending"++ putStrLn "Support flushing compressed data: Pending"+ putStrLn "Return appropriate error statuses for malformed compressed input: Pending"+-}++onTheFlyFileCompressTest :: IO ()+onTheFlyFileCompressTest = do+ let settings = defaultSettings { brotliFilesBehavior = BrotliCompress, brotliMinimumSize = 5 }+ let app req respond =+ respond $+ responseFile+ status200+ [ ("Content-Type", "text/whatever")+ ]+ "test/sample.txt"+ Nothing+ runSession session $ brotli settings app+ where+ session = do+ resp <- request (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ liftIO $ assert (decompress (simpleBody resp) @=? "Hello world\n")++precompressedTest :: IO ()+precompressedTest = do+ let settings = defaultSettings { brotliFilesBehavior = BrotliPreCompressed BrotliIgnore }+ let app req respond =+ respond $+ responseFile+ status200+ [ ("Content-Type", "text/whatever")+ ]+ "test/words"+ Nothing+ var <- newEmptyMVar+ (brotli settings app)+ (defaultRequest {requestHeaders = [("Accept-Encoding", "br")]})+ (\resp -> do+ putMVar var resp+ return ResponseReceived)+ resp <- takeMVar var+ case resp of+ ResponseFile stats hs fp Nothing -> do+ assert (fp == "test/words.br")+ _ -> error "Not what we expected"
+ wai-middleware-brotli.cabal view
@@ -0,0 +1,62 @@+name: wai-middleware-brotli+version: 0.1.0.0+synopsis: WAI middleware for brotli compression+-- description:+homepage: https://github.com/iand675/wai-middleware-brotli#readme+license: BSD3+license-file: LICENSE+author: Ian Duncan+maintainer: ian@iankduncan.com+copyright: Ian Duncan+category: Web+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10++library+ hs-source-dirs: src+ exposed-modules: Network.Wai.Middleware.Brotli+ build-depends: base >= 4.7 && < 5+ , wai+ , http-types+ , hs-brotli+ , bytestring+ , directory+ , filepath+ , binary+ , unix+ default-language: Haskell2010++executable wai-middleware-brotli-server+ hs-source-dirs: app+ main-is: Main.hs+ build-depends: base+ , http-types+ , warp+ , wai-extra+ , wai-middleware-brotli+ , wai+ , wai-app-static+ default-language: Haskell2010++test-suite wai-middleware-brotli-test+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Spec.hs+ build-depends: base+ , hs-brotli+ , bytestring+ , http-types+ , mtl+ , tasty+ , tasty-hspec+ , tasty-hunit+ , wai+ , wai-extra+ , wai-middleware-brotli+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/iand675/hs-brotli