├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .hlint.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── cabal.project ├── source └── library │ └── Wuss.hs └── wuss.cabal /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | updates: 2 | - directory: / 3 | package-ecosystem: github-actions 4 | schedule: 5 | interval: weekly 6 | version: 2 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | build: 3 | name: GHC ${{ matrix.ghc }} on ${{ matrix.os }} 4 | runs-on: ${{ matrix.os }} 5 | steps: 6 | - uses: actions/checkout@v4 7 | - run: mkdir artifact 8 | - uses: haskell/ghcup-setup@v1 9 | with: 10 | ghc: ${{ matrix.ghc }} 11 | cabal: latest 12 | - run: ghc-pkg list 13 | - run: cabal sdist --output-dir artifact 14 | - run: cabal configure --enable-documentation --flags=pedantic --haddock-for-hackage --jobs 15 | - run: cat cabal.project.local 16 | - run: cp cabal.project.local artifact 17 | - run: cabal update 18 | - run: cabal freeze 19 | - run: cat cabal.project.freeze 20 | - run: cp cabal.project.freeze artifact 21 | - run: cabal outdated --v2-freeze-file 22 | - uses: actions/cache@v4 23 | with: 24 | key: ${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }} 25 | path: ~/.local/state/cabal 26 | restore-keys: ${{ matrix.os }}-${{ matrix.ghc }}- 27 | - run: cabal build --only-download 28 | - run: cabal build --only-dependencies 29 | - run: cabal build 30 | - run: cp dist-newstyle/wuss-*-docs.tar.gz artifact 31 | - run: tar --create --file artifact.tar --verbose artifact 32 | - uses: actions/upload-artifact@v4 33 | with: 34 | name: wuss-${{ github.sha }}-${{ matrix.os }}-${{ matrix.ghc }} 35 | path: artifact.tar 36 | strategy: 37 | matrix: 38 | include: 39 | - ghc: 9.12 40 | os: macos-13 41 | - ghc: 9.12 42 | os: macos-14 43 | - ghc: 9.8 44 | os: ubuntu-24.04 45 | - ghc: '9.10' 46 | os: ubuntu-24.04 47 | - ghc: 9.12 48 | os: ubuntu-24.04 49 | - ghc: 9.12 50 | os: windows-2022 51 | cabal: 52 | name: Cabal 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | - run: cabal check 57 | gild: 58 | name: Gild 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: tfausak/cabal-gild-setup-action@v2 63 | - run: cabal-gild --input wuss.cabal --mode check 64 | hlint: 65 | name: HLint 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: haskell-actions/hlint-setup@v2 70 | - uses: haskell-actions/hlint-run@v2 71 | with: 72 | fail-on: status 73 | ormolu: 74 | name: Ormolu 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: haskell-actions/run-ormolu@v17 79 | release: 80 | if: ${{ github.event_name == 'release' }} 81 | name: Release 82 | needs: build 83 | permissions: 84 | contents: write 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: actions/download-artifact@v4 88 | with: 89 | name: wuss-${{ github.sha }}-ubuntu-24.04-9.12 90 | - run: tar --extract --file artifact.tar --verbose 91 | - uses: softprops/action-gh-release@v2 92 | with: 93 | files: artifact/wuss-${{ github.event.release.tag_name }}.tar.gz 94 | - run: cabal upload --publish --username '${{ secrets.HACKAGE_USERNAME }}' --password '${{ secrets.HACKAGE_PASSWORD }}' artifact/wuss-${{ github.event.release.tag_name }}.tar.gz 95 | - run: cabal --http-transport=plain-http upload --documentation --publish --username '${{ secrets.HACKAGE_USERNAME }}' --password '${{ secrets.HACKAGE_PASSWORD }}' artifact/wuss-${{ github.event.release.tag_name }}-docs.tar.gz 96 | name: CI 97 | on: 98 | pull_request: 99 | branches: 100 | - main 101 | push: 102 | branches: 103 | - main 104 | release: 105 | types: 106 | - created 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | /cabal.project.* 3 | /dist-newstyle/ 4 | -------------------------------------------------------------------------------- /.hlint.yaml: -------------------------------------------------------------------------------- 1 | - group: 2 | enabled: true 3 | name: dollar 4 | - group: 5 | enabled: true 6 | name: generalise 7 | - ignore: 8 | name: Use lambda-case 9 | - ignore: 10 | name: Use tuple-section 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | Wuss follows the [Package Versioning Policy](https://pvp.haskell.org). 4 | You can find release notes [on GitHub](https://github.com/tfausak/wuss/releases). 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Taylor Fausak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wuss 2 | 3 | [![CI](https://github.com/tfausak/wuss/actions/workflows/ci.yml/badge.svg)](https://github.com/tfausak/wuss/actions/workflows/ci.yml) 4 | [![Hackage](https://badgen.net/hackage/v/wuss)](https://hackage.haskell.org/package/wuss) 5 | 6 | Secure WebSocket (WSS) clients in Haskell. 7 | 8 | --- 9 | 10 | Wuss is a library that lets you easily create secure WebSocket clients over the 11 | WSS protocol. It is a small addition to [the `websockets` package][] and is 12 | adapted from existing solutions by [@jaspervdj][], [@mpickering][], and 13 | [@elfenlaid][]. 14 | 15 | - [Installation](#installation) 16 | - [Usage](#usage) 17 | 18 | ## Installation 19 | 20 | To add Wuss as a dependency to your package, add it to your Cabal file. 21 | 22 | ``` 23 | build-depends: wuss 24 | ``` 25 | 26 | For other use cases, install it with Cabal. 27 | 28 | ``` sh 29 | $ cabal install wuss 30 | ``` 31 | 32 | ## Usage 33 | 34 | ``` hs 35 | import Wuss 36 | 37 | import Control.Concurrent (forkIO) 38 | import Control.Monad (forever, unless, void) 39 | import Data.Text (Text, pack) 40 | import Network.WebSockets (ClientApp, receiveData, sendClose, sendTextData) 41 | 42 | main :: IO () 43 | main = runSecureClient "echo.websocket.org" 443 "/" ws 44 | 45 | ws :: ClientApp () 46 | ws connection = do 47 | putStrLn "Connected!" 48 | 49 | void . forkIO . forever $ do 50 | message <- receiveData connection 51 | print (message :: Text) 52 | 53 | let loop = do 54 | line <- getLine 55 | unless (null line) $ do 56 | sendTextData connection (pack line) 57 | loop 58 | loop 59 | 60 | sendClose connection (pack "Bye!") 61 | ``` 62 | 63 | For more information about Wuss, please read [the Haddock documentation][]. 64 | 65 | [the `websockets` package]: https://hackage.haskell.org/package/websockets 66 | [@jaspervdj]: https://gist.github.com/jaspervdj/7198388 67 | [@mpickering]: https://gist.github.com/mpickering/f1b7ba3190a4bb5884f3 68 | [@elfenlaid]: https://gist.github.com/elfenlaid/7b5c28065e67e4cf0767 69 | [semantic versioning]: http://semver.org/spec/v2.0.0.html 70 | [the change log]: CHANGELOG.md 71 | [the haddock documentation]: https://hackage.haskell.org/package/wuss 72 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: . 2 | -------------------------------------------------------------------------------- /source/library/Wuss.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE NoImplicitPrelude #-} 2 | 3 | -- | 4 | -- Wuss is a library that lets you easily create secure WebSocket clients over 5 | -- the WSS protocol. It is a small addition to 6 | -- 7 | -- and is adapted from existing solutions by 8 | -- , 9 | -- , and 10 | -- . 11 | -- 12 | -- == Example 13 | -- 14 | -- > import Wuss 15 | -- > 16 | -- > import Control.Concurrent (forkIO) 17 | -- > import Control.Monad (forever, unless, void) 18 | -- > import Data.Text (Text, pack) 19 | -- > import Network.WebSockets (ClientApp, receiveData, sendClose, sendTextData) 20 | -- > 21 | -- > main :: IO () 22 | -- > main = runSecureClient "echo.websocket.org" 443 "/" ws 23 | -- > 24 | -- > ws :: ClientApp () 25 | -- > ws connection = do 26 | -- > putStrLn "Connected!" 27 | -- > 28 | -- > void . forkIO . forever $ do 29 | -- > message <- receiveData connection 30 | -- > print (message :: Text) 31 | -- > 32 | -- > let loop = do 33 | -- > line <- getLine 34 | -- > unless (null line) $ do 35 | -- > sendTextData connection (pack line) 36 | -- > loop 37 | -- > loop 38 | -- > 39 | -- > sendClose connection (pack "Bye!") 40 | -- 41 | -- == Retry 42 | -- 43 | -- Note that it is possible for the connection itself or any message to fail and need to be retried. 44 | -- Fortunately this can be handled by something like . 45 | -- See for an example. 46 | module Wuss 47 | ( runSecureClient, 48 | newSecureClientConnection, 49 | runSecureClientWith, 50 | newSecureClientConnectionWith, 51 | Config (..), 52 | defaultConfig, 53 | runSecureClientWithConfig, 54 | newSecureClientConnectionWithConfig, 55 | ) 56 | where 57 | 58 | import qualified Control.Applicative as Applicative 59 | import qualified Control.Exception as Exception 60 | import qualified Control.Monad.Catch as Catch 61 | import qualified Control.Monad.IO.Class as MonadIO 62 | import qualified Data.ByteString as StrictBytes 63 | import qualified Data.ByteString.Lazy as LazyBytes 64 | import qualified Data.Default as Default 65 | import qualified Data.Maybe as Maybe 66 | import qualified Data.String as String 67 | import qualified Network.Connection as Connection 68 | import qualified Network.Socket as Socket 69 | import qualified Network.WebSockets as WebSockets 70 | import qualified Network.WebSockets.Stream as Stream 71 | import qualified System.IO as IO 72 | import qualified System.IO.Error as IO.Error 73 | import Prelude (($), (.)) 74 | 75 | -- | 76 | -- A secure replacement for 'Network.WebSockets.runClient'. 77 | -- 78 | -- >>> let app _connection = return () 79 | -- >>> runSecureClient "echo.websocket.org" 443 "/" app 80 | runSecureClient :: 81 | (MonadIO.MonadIO m) => 82 | (Catch.MonadMask m) => 83 | -- | Host 84 | Socket.HostName -> 85 | -- | Port 86 | Socket.PortNumber -> 87 | -- | Path 88 | String.String -> 89 | -- | Application 90 | WebSockets.ClientApp a -> 91 | m a 92 | runSecureClient host port path app = do 93 | let options = WebSockets.defaultConnectionOptions 94 | runSecureClientWith host port path options [] app 95 | 96 | -- | Build a new `Connection` from the client's point of view. 97 | -- 98 | -- /WARNING/: Be sure to run the returned `IO ()` action after you are done 99 | -- using the `Connection` in order to properly close the communication channel. 100 | -- `runSecureClient` handles this for you, prefer to use it when possible. 101 | newSecureClientConnection :: 102 | (MonadIO.MonadIO m) => 103 | (Catch.MonadMask m) => 104 | -- | Host 105 | Socket.HostName -> 106 | -- | PortNumber 107 | Socket.PortNumber -> 108 | -- | Path 109 | String.String -> 110 | m (WebSockets.Connection, IO.IO ()) 111 | newSecureClientConnection host port path = do 112 | let options = WebSockets.defaultConnectionOptions 113 | newSecureClientConnectionWith host port path options [] 114 | 115 | -- | 116 | -- A secure replacement for 'Network.WebSockets.runClientWith'. 117 | -- 118 | -- >>> let options = defaultConnectionOptions 119 | -- >>> let headers = [] 120 | -- >>> let app _connection = return () 121 | -- >>> runSecureClientWith "echo.websocket.org" 443 "/" options headers app 122 | -- 123 | -- If you want to run a secure client without certificate validation, use 124 | -- 'Network.WebSockets.runClientWithStream'. For example: 125 | -- 126 | -- > let host = "echo.websocket.org" 127 | -- > let port = 443 128 | -- > let path = "/" 129 | -- > let options = defaultConnectionOptions 130 | -- > let headers = [] 131 | -- > let tlsSettings = TLSSettingsSimple 132 | -- > -- This is the important setting. 133 | -- > { settingDisableCertificateValidation = True 134 | -- > , settingDisableSession = False 135 | -- > , settingUseServerName = False 136 | -- > } 137 | -- > let connectionParams = ConnectionParams 138 | -- > { connectionHostname = host 139 | -- > , connectionPort = port 140 | -- > , connectionUseSecure = Just tlsSettings 141 | -- > , connectionUseSocks = Nothing 142 | -- > } 143 | -- > 144 | -- > context <- initConnectionContext 145 | -- > connection <- connectTo context connectionParams 146 | -- > stream <- makeStream 147 | -- > (fmap Just (connectionGetChunk connection)) 148 | -- > (maybe (return ()) (connectionPut connection . toStrict)) 149 | -- > runClientWithStream stream host path options headers $ \ connection -> do 150 | -- > -- Do something with the connection. 151 | -- > return () 152 | runSecureClientWith :: 153 | (MonadIO.MonadIO m) => 154 | (Catch.MonadMask m) => 155 | -- | Host 156 | Socket.HostName -> 157 | -- | Port 158 | Socket.PortNumber -> 159 | -- | Path 160 | String.String -> 161 | -- | Options 162 | WebSockets.ConnectionOptions -> 163 | -- | Headers 164 | WebSockets.Headers -> 165 | -- | Application 166 | WebSockets.ClientApp a -> 167 | m a 168 | runSecureClientWith host port path options headers app = do 169 | let config = defaultConfig 170 | runSecureClientWithConfig host port path config options headers app 171 | 172 | -- | Build a new `Connection` from the client's point of view. 173 | -- 174 | -- /WARNING/: Be sure to run the returned `IO ()` action after you are done 175 | -- using the `Connection` in order to properly close the communication channel. 176 | -- `runSecureClientWith` handles this for you, prefer to use it when possible. 177 | newSecureClientConnectionWith :: 178 | (MonadIO.MonadIO m) => 179 | (Catch.MonadMask m) => 180 | -- | Host 181 | Socket.HostName -> 182 | -- | PortNumber 183 | Socket.PortNumber -> 184 | -- | Path 185 | String.String -> 186 | -- | Options 187 | WebSockets.ConnectionOptions -> 188 | -- | Headers 189 | WebSockets.Headers -> 190 | m (WebSockets.Connection, IO.IO ()) 191 | newSecureClientConnectionWith host port path options headers = do 192 | let config = defaultConfig 193 | newSecureClientConnectionWithConfig host port path config options headers 194 | 195 | -- | Configures a secure WebSocket connection. 196 | newtype Config = Config 197 | { -- | How to get bytes from the connection. Typically 198 | -- 'Connection.connectionGetChunk', but could be something else like 199 | -- 'Connection.connectionGetLine'. 200 | connectionGet :: Connection.Connection -> IO.IO StrictBytes.ByteString 201 | } 202 | 203 | -- | The default 'Config' value used by 'runSecureClientWith'. 204 | defaultConfig :: Config 205 | defaultConfig = do 206 | Config {connectionGet = Connection.connectionGetChunk} 207 | 208 | -- | Runs a secure WebSockets client with the given 'Config'. 209 | runSecureClientWithConfig :: 210 | (MonadIO.MonadIO m) => 211 | (Catch.MonadMask m) => 212 | -- | Host 213 | Socket.HostName -> 214 | -- | Port 215 | Socket.PortNumber -> 216 | -- | Path 217 | String.String -> 218 | -- | Config 219 | Config -> 220 | -- | Options 221 | WebSockets.ConnectionOptions -> 222 | -- | Headers 223 | WebSockets.Headers -> 224 | -- | Application 225 | WebSockets.ClientApp a -> 226 | m a 227 | runSecureClientWithConfig host port path config options headers app = 228 | Catch.bracket 229 | (newSecureClientConnectionWithConfig host port path config options headers) 230 | (\(_, close) -> MonadIO.liftIO close) 231 | (\(conn, _) -> MonadIO.liftIO (app conn)) 232 | 233 | -- | Build a new `Connection` from the client's point of view. 234 | -- 235 | -- /WARNING/: Be sure to run the returned `IO ()` action after you are done 236 | -- using the `Connection` in order to properly close the communication channel. 237 | -- `runSecureClientWithConfig` handles this for you, prefer to use it when 238 | -- possible. 239 | newSecureClientConnectionWithConfig :: 240 | (MonadIO.MonadIO m) => 241 | (Catch.MonadMask m) => 242 | -- | Host 243 | Socket.HostName -> 244 | -- | PortNumber 245 | Socket.PortNumber -> 246 | -- | Path 247 | String.String -> 248 | -- | Config 249 | Config -> 250 | -- | Options 251 | WebSockets.ConnectionOptions -> 252 | -- | Headers 253 | WebSockets.Headers -> 254 | m (WebSockets.Connection, IO.IO ()) 255 | newSecureClientConnectionWithConfig host port path config options headers = do 256 | context <- MonadIO.liftIO Connection.initConnectionContext 257 | Catch.bracketOnError 258 | (MonadIO.liftIO $ Connection.connectTo context (connectionParams host port)) 259 | (MonadIO.liftIO . Connection.connectionClose) 260 | ( \connection -> MonadIO.liftIO $ do 261 | stream <- 262 | Stream.makeStream 263 | (reader config connection) 264 | (writer connection) 265 | conn <- WebSockets.newClientConnection stream host path options headers 266 | Applicative.pure (conn, Connection.connectionClose connection) 267 | ) 268 | 269 | connectionParams :: 270 | Socket.HostName -> Socket.PortNumber -> Connection.ConnectionParams 271 | connectionParams host port = do 272 | Connection.ConnectionParams 273 | { Connection.connectionHostname = host, 274 | Connection.connectionPort = port, 275 | Connection.connectionUseSecure = Maybe.Just tlsSettings, 276 | Connection.connectionUseSocks = Maybe.Nothing 277 | } 278 | 279 | tlsSettings :: Connection.TLSSettings 280 | tlsSettings = Default.def 281 | 282 | reader :: 283 | Config -> 284 | Connection.Connection -> 285 | IO.IO (Maybe.Maybe StrictBytes.ByteString) 286 | reader config connection = 287 | IO.Error.catchIOError 288 | ( do 289 | chunk <- connectionGet config connection 290 | Applicative.pure (Maybe.Just chunk) 291 | ) 292 | ( \e -> 293 | if IO.Error.isEOFError e 294 | then Applicative.pure Maybe.Nothing 295 | else Exception.throwIO e 296 | ) 297 | 298 | writer :: 299 | Connection.Connection -> Maybe.Maybe LazyBytes.ByteString -> IO.IO () 300 | writer connection maybeBytes = do 301 | case maybeBytes of 302 | Maybe.Nothing -> do 303 | Applicative.pure () 304 | Maybe.Just bytes -> do 305 | Connection.connectionPut connection (LazyBytes.toStrict bytes) 306 | -------------------------------------------------------------------------------- /wuss.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.2 2 | name: wuss 3 | version: 2.0.2.5 4 | synopsis: Secure WebSocket (WSS) clients 5 | description: 6 | Wuss is a library that lets you easily create secure WebSocket clients over 7 | the WSS protocol. It is a small addition to 8 | and 9 | is adapted from existing solutions by 10 | , 11 | , and 12 | . 13 | 14 | build-type: Simple 15 | category: Network 16 | extra-doc-files: 17 | CHANGELOG.md 18 | README.md 19 | 20 | license-file: LICENSE.txt 21 | license: MIT 22 | maintainer: Taylor Fausak 23 | 24 | source-repository head 25 | location: https://github.com/tfausak/wuss 26 | type: git 27 | 28 | flag pedantic 29 | default: False 30 | manual: True 31 | 32 | common library 33 | build-depends: base ^>=4.19.0.0 || ^>=4.20.0.0 || ^>=4.21.0.0 34 | build-depends: 35 | bytestring ^>=0.11.4.0 || ^>=0.12.0.2, 36 | crypton-connection ^>=0.3.2 || ^>=0.4.0, 37 | data-default ^>=0.7.0 || ^>=0.8.0.0, 38 | exceptions ^>=0.10.7, 39 | network ^>=3.1.4.0 || ^>=3.2.0.0, 40 | websockets ^>=0.12.7.3 || ^>=0.13.0.0, 41 | 42 | default-language: Haskell2010 43 | ghc-options: 44 | -Weverything 45 | -Wno-missing-exported-signatures 46 | -Wno-missing-kind-signatures 47 | -Wno-missing-safe-haskell-mode 48 | -Wno-prepositive-qualified-module 49 | -Wno-unsafe 50 | 51 | if flag(pedantic) 52 | ghc-options: -Werror 53 | 54 | library 55 | import: library 56 | -- cabal-gild: discover source/library 57 | exposed-modules: Wuss 58 | hs-source-dirs: source/library 59 | --------------------------------------------------------------------------------