packages feed

datastar-hs-1.0.2.0: src/Hypermedia/Datastar/Compression/Zstd.hs

{- |
A 'Compressor' that compresses an SSE stream with zstd (@Content-Encoding: zstd@),
driving libzstd's streaming API directly so each event can be flushed.
-}
module Hypermedia.Datastar.Compression.Zstd
  ( zstd
  , zstdWith
  , defaultZstdLevel
  )
where

import Control.Exception (Exception, throwIO)
import Control.Monad (unless, when)

import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as BSB
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Unsafe qualified as BU
import Data.Word (Word8)

import Foreign.ForeignPtr
  ( ForeignPtr
  , finalizeForeignPtr
  , mallocForeignPtrBytes
  , newForeignPtr
  , withForeignPtr
  )
import Foreign.Marshal.Alloc (finalizerFree, malloc)
import Foreign.Ptr (Ptr, castPtr, nullPtr)
import Foreign.Storable (peek, poke)

import Codec.Compression.Zstd.FFI
  ( Buffer (..)
  , In
  , Out
  , compressStream
  , createCStream
  , cstreamOutSize
  , endStream
  , flushStream
  , getErrorName
  , initCStream
  , isError
  , p_freeCStream
  )

import Hypermedia.Datastar.WAI (Compressor (..))

newtype ZstdError = ZstdError String
  deriving (Show)

instance Exception ZstdError

-- | zstd's default compression level (3).
defaultZstdLevel :: Int
defaultZstdLevel = 3

-- | A zstd 'Compressor' (@Content-Encoding: zstd@) at 'defaultZstdLevel'.
zstd :: Compressor
zstd = zstdWith defaultZstdLevel

-- | A zstd 'Compressor' at an explicit compression level (1–22).
zstdWith :: Int -> Compressor
zstdWith level =
  Compressor
    { compressorEncoding = "zstd"
    , compressorWrap = \rawWrite rawFlush -> do
        let out = rawWrite . BSB.byteString
            outSize = fromIntegral cstreamOutSize :: Int

        csPtr <- createCStream
        when (csPtr == nullPtr) $ throwIO (ZstdError "ZSTD_createCStream returned NULL")
        initRet <- initCStream csPtr (fromIntegral level)
        when (isError initRet) $
          throwIO (ZstdError ("ZSTD_initCStream: " <> getErrorName initRet))
        csFp <- newForeignPtr p_freeCStream csPtr

        inFp <- newForeignPtr finalizerFree =<< (malloc :: IO (Ptr (Buffer In)))
        outFp <- newForeignPtr finalizerFree =<< (malloc :: IO (Ptr (Buffer Out)))
        scratchFp <- mallocForeignPtrBytes outSize :: IO (ForeignPtr Word8)

        let
          drainOut outBuf scratch = do
            produced <- fromIntegral . bufPos <$> peek outBuf
            when (produced > 0) $
              out =<< BS.packCStringLen (castPtr scratch, produced)

          resetOut outBuf scratch =
            poke outBuf (Buffer scratch (fromIntegral outSize) 0)

          checkRet ctx ret =
            when (isError ret) $ throwIO (ZstdError (ctx <> ": " <> getErrorName ret))

          write builder =
            let bs = BL.toStrict (BSB.toLazyByteString builder)
             in unless (BS.null bs) $
                  BU.unsafeUseAsCStringLen bs $ \(srcPtr, len) ->
                    withForeignPtr csFp $ \cs ->
                      withForeignPtr inFp $ \inBuf ->
                        withForeignPtr outFp $ \outBuf ->
                          withForeignPtr scratchFp $ \scratch -> do
                            poke inBuf (Buffer (castPtr srcPtr) (fromIntegral len) 0)
                            let loop = do
                                  resetOut outBuf scratch
                                  checkRet "ZSTD_compressStream" =<< compressStream cs outBuf inBuf
                                  drainOut outBuf scratch
                                  consumed <- fromIntegral . bufPos <$> peek inBuf
                                  when (consumed < len) loop
                            loop

          pump name op =
            withForeignPtr csFp $ \cs ->
              withForeignPtr outFp $ \outBuf ->
                withForeignPtr scratchFp $ \scratch -> do
                  let loop = do
                        resetOut outBuf scratch
                        remaining <- op cs outBuf
                        checkRet name remaining
                        drainOut outBuf scratch
                        when (remaining > 0) loop
                  loop

          flush = pump "ZSTD_flushStream" flushStream >> rawFlush

          finish = do
            pump "ZSTD_endStream" endStream
            finalizeForeignPtr csFp
            rawFlush

        pure (write, flush, finish)
    }