packages feed

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 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