box-socket 0.0.2 → 0.5.3.0
raw patch · 13 files changed
Files
- ChangeLog.md +12/−0
- LICENSE +30/−0
- Setup.hs +2/−0
- app/box-socket.hs +44/−85
- box-socket.cabal +75/−71
- readme.md +29/−0
- src/Box/Socket.hs +0/−163
- src/Box/Socket/Types.hs +14/−0
- src/Box/TCP.hs +147/−0
- src/Box/TCP/Example.hs +83/−0
- src/Box/Websocket.hs +238/−0
- src/Box/Websocket/Example.hs +86/−0
- test/test.hs +0/−12
+ ChangeLog.md view
@@ -0,0 +1,12 @@+0.5.2+===+* dependency bumps for 9.10++0.5+===+* complete refactor++* aligned TCP api with Websockets++* Action & Codensity-based API surface+
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2017, Tony Day++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 Tony Day 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.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
app/box-socket.hs view
@@ -1,105 +1,64 @@-{-# LANGUAGE DataKinds #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE NoImplicitPrelude #-}-{-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE QuasiQuotes #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -Wall #-}-{-# OPTIONS_GHC -Wno-unused-do-bind #-} {- -It's a box. It's a socket.+It's a box. It's a socket. It's an app. -} module Main where -import Box-import Box.Socket-import Control.Lens hiding (Wrapped, Unwrapped)-import Data.Generics.Labels ()-import NumHask.Prelude hiding (STM, bracket)-import Options.Generic-import Control.Concurrent.Classy.Async as C+import Box.TCP.Example as TCP+import Box.Websocket.Example as Websocket+import Options.Applicative -data SocketType = Client | Responder | TestRun deriving (Eq, Read, Show, Generic)+boxSocketOpts :: ParserInfo Opts+boxSocketOpts =+ info+ (boxSocketOptions <**> helper)+ (fullDesc <> progDesc "box-socket tests" <> header "examples of box socket usage") -instance ParseField SocketType+data SocketType = TCPSocket | WebSocket deriving (Eq, Read, Show) -instance ParseRecord SocketType+data ExampleType = ClientIO | ServerIO | EchoExample | SenderExample deriving (Eq, Read, Show) -instance ParseFields SocketType+data Opts = Opts+ { socketType :: SocketType,+ exampleType :: ExampleType+ }+ deriving (Eq, Show) -data Opts w- = Opts- { apptype :: w ::: SocketType <?> "type of websocket app"- }- deriving (Generic)+boxSocketOptions :: Parser Opts+boxSocketOptions =+ Opts+ <$> parseSocketType+ <*> parseExampleType -instance ParseRecord (Opts Wrapped)+parseSocketType :: Parser SocketType+parseSocketType =+ flag' WebSocket (long "websocket" <> help "websocket socket")+ <|> flag' TCPSocket (long "tcp" <> help "tcp socket")+ <|> pure WebSocket +parseExampleType :: Parser ExampleType+parseExampleType =+ flag' ClientIO (long "clientio" <> help "client socket")+ <|> flag' ServerIO (long "serverio" <> help "server socket")+ <|> flag' EchoExample (long "echo" <> help "responder example")+ <|> flag' SenderExample (long "sender" <> help "sender example")+ <|> pure EchoExample+ main :: IO () main = do- o :: Opts Unwrapped <- unwrapRecord "example websocket apps"- r :: Text <- case apptype o of- Client -> show <$> clientIO- Responder -> show <$> q' serverIO- TestRun -> show <$> testRun+ o <- execParser boxSocketOpts+ r <- case (socketType o, exampleType o) of+ (WebSocket, ClientIO) -> show <$> Websocket.clientIO+ (WebSocket, ServerIO) -> show <$> Websocket.serverIO+ (WebSocket, SenderExample) -> show <$> Websocket.senderExample ["hi", "bye"]+ (WebSocket, EchoExample) -> show <$> Websocket.echoExample ["hi", "bye"]+ (TCPSocket, ClientIO) -> show <$> TCP.clientIO+ (TCPSocket, ServerIO) -> show <$> TCP.serverIO+ (TCPSocket, SenderExample) -> show <$> TCP.senderExample ["hi", "bye"]+ (TCPSocket, EchoExample) -> show <$> TCP.echoExample ["hi", "bye"] putStrLn r---- * older stuff-serverIO :: IO ()-serverIO = runServer defaultSocketConfig- (responderApp (\x -> bool (Right $ "echo:" <> x) (Left "quit") (x=="q")))--clientIO :: IO ()-clientIO =- (runClient defaultSocketConfig . clientApp)- (Box (contramap show toStdout) fromStdin)--q' :: IO a -> IO (Either () a)-q' f = C.race (cancelQ fromStdin) f--cancelQ :: Emitter IO Text -> IO ()-cancelQ e = do- e' <- emit e- case e' of- Just "q" -> pure ()- _ -> do- putStrLn ("nothing happens" :: Text)- cancelQ e---- | test of clientApp via a cRef committer and a canned list of Text-tClient :: [Text] -> IO [Either Text Text]-tClient xs = do- (c,r) <- cRef- runClient defaultSocketConfig- (\conn ->- (\b -> clientApp b conn) <$.>- (Box <$>- pure c <*>- fromListE (xs <> ["q"])))- r--tClientIO :: [Text] -> IO ()-tClientIO xs =- (runClient defaultSocketConfig . clientApp) <$.>- (Box (contramap show toStdout) <$> (fromListE (xs <> ["q"])))---- | main test run of client-server functionality--- the code starts a server in a thread, starts the client in the main thread, and cancels the server on completion.--- >>> testRun--- [Left "receiver: received: echo:1",Right "echo:1",Left "receiver: received: echo:2",Right "echo:2",Left "receiver: received: echo:3",Right "echo:3",Left "receiver: received: close: 1000 \"received close signal: responder closed.\""]-testRun :: IO [Either Text Text]-testRun = do- a <- C.async (runServer defaultSocketConfig (responderApp (\x -> bool (Right $ "echo:" <> x) (Left "quit") (x=="q"))))- sleep 0.1- r <- tClient (show <$> [1..3::Int])- C.cancel a- pure r-
box-socket.cabal view
@@ -1,92 +1,96 @@-cabal-version: 2.4-name: box-socket-version: 0.0.2-synopsis: See readme.md-description: See readme.md for description.-category: project+cabal-version: 3.0+name: box-socket+version: 0.5.3.0+license: BSD-3-Clause+license-file: LICENSE+copyright: Tony Day (c) 2017+category: web author: Tony Day maintainer: tonyday567@gmail.com-copyright: Tony Day (c) AfterTimes-license: BSD-3-Clause homepage: https://github.com/tonyday567/box-socket#readme bug-reports: https://github.com/tonyday567/box-socket/issues+synopsis: Box websockets+description: Websockets built with the box library. build-type: Simple+tested-with:+ ghc ==9.10.2+ ghc ==9.12.2+ ghc ==9.14.1++extra-doc-files:+ ChangeLog.md+ readme.md+ source-repository head type: git location: https://github.com/tonyday567/box-socket -library- exposed-modules:- Box.Socket- hs-source-dirs:- src- build-depends:- base >= 4.12 && <5,- box >= 0.6 && < 0.7,- concurrency >= 1.11,- exceptions >= 0.10,- generic-lens >= 1.1.0 && < 3.0,- lens >= 4.17.1 && < 4.20,- numhask >= 0.6,- websockets >= 0.12,- default-language: Haskell2010- default-extensions:- NegativeLiterals- NoImplicitPrelude- OverloadedStrings- UnicodeSyntax+common ghc-options-exe-stanza ghc-options:+ -fforce-recomp+ -funbox-strict-fields+ -rtsopts+ -threaded+ -with-rtsopts=-N++common ghc-options-stanza+ ghc-options: -Wall -Wcompat+ -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns+ -Wpartial-fields -Wredundant-constraints +common ghc2024-additions+ default-extensions:+ DataKinds+ DerivingStrategies+ DisambiguateRecordFields+ ExplicitNamespaces+ GADTs+ LambdaCase+ MonoLocalBinds+ RoleAnnotations++common ghc2024-stanza+ if impl(ghc >=9.10)+ default-language:+ GHC2024+ else+ import: ghc2024-additions+ default-language:+ GHC2021++library+ import: ghc-options-stanza+ import: ghc2024-stanza+ hs-source-dirs: src+ build-depends:+ async >=2.2.3 && <2.3,+ base >=4.14 && <5,+ box >=0.9.3 && <0.10,+ bytestring >=0.11.3 && <0.13,+ network-simple >=0.4 && <0.5,+ profunctors >=5.6 && <5.7,+ text >=1.2 && <2.2,+ websockets >=0.12 && <0.14,++ exposed-modules:+ Box.Socket.Types+ Box.TCP+ Box.TCP.Example+ Box.Websocket+ Box.Websocket.Example+ executable box-socket+ import: ghc-options-exe-stanza+ import: ghc-options-stanza+ import: ghc2024-stanza main-is: box-socket.hs hs-source-dirs: app build-depends:- base >= 4.7 && < 5,- box,- box-socket,- concurrency >= 1.11,- generic-lens >= 2.0,- lens >= 4.19,- numhask >= 0.6,- optparse-generic >= 1.3,- default-language: Haskell2010- default-extensions:- NegativeLiterals- NoImplicitPrelude- OverloadedStrings- UnicodeSyntax- ghc-options:- -funbox-strict-fields- -fforce-recomp- -threaded- -rtsopts- -with-rtsopts=-N--test-suite test- type: exitcode-stdio-1.0- main-is: test.hs- hs-source-dirs:- test- build-depends:- base >=4.7 && <5,+ base >=4.14 && <5, box-socket,- doctest >= 0.16,- numhask >= 0.6,- default-language: Haskell2010- default-extensions:- NegativeLiterals- NoImplicitPrelude- OverloadedStrings- UnicodeSyntax- ghc-options:- -Wall- -Wcompat- -Wincomplete-record-updates- -Wincomplete-uni-patterns- -Wredundant-constraints- -funbox-strict-fields+ optparse-applicative >=0.17 && <0.20,
+ readme.md view
@@ -0,0 +1,29 @@+[](https://hackage.haskell.org/package/box-socket) [](https://github.com/tonyday567/box/actions/workflows/haskell-ci.yml)++Socket API based on the box library, with websockets and TCP support.+++# Usage++ :set -XOverloadedStrings+ import Box+ import Box.Socket.Types+ import Box.Websocket++IO client:++ clientBox defaultSocketConfig (CloseAfter 0) (stdBox "q")++IO server:++ serverBox defaultSocketConfig (CloseAfter 0) (stdBox "q")++See examples in Box.Websocket.Example and Box.TCP.Example for a variety of usage.+++# Design++- The API attempts to be similar for TCP and Websocket++- A Codensity, continuation passing style is encouraged, similar to the box library.+
− src/Box/Socket.hs
@@ -1,163 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE QuasiQuotes #-}-{-# OPTIONS_GHC -Wall #-}-{-# OPTIONS_GHC -Wno-unused-do-bind #-}--module Box.Socket- ( SocketConfig(..),- defaultSocketConfig,- runClient,- runServer,- connect,- clientApp,- responderApp,- serverApp,- receiver',- receiver,- sender,- responder,- )-where--import qualified Network.WebSockets as WS-import Box-import Control.Lens-import NumHask.Prelude hiding (bracket)-import Data.Generics.Labels ()-import Control.Monad.Conc.Class as C-import Control.Monad.Catch-import qualified Control.Concurrent.Classy.Async as C--data SocketConfig- = SocketConfig- { host :: Text,- port :: Int,- path :: Text- }- deriving (Show, Eq, Generic)--defaultSocketConfig :: SocketConfig-defaultSocketConfig = SocketConfig "127.0.0.1" 9160 "/"--runClient :: (MonadIO m) => SocketConfig -> WS.ClientApp () -> m ()-runClient c app = liftIO $ WS.runClient (unpack $ c ^. #host) (c ^. #port) (unpack $ c ^. #path) app--runServer :: (MonadIO m) => SocketConfig -> WS.ServerApp -> m ()-runServer c app = liftIO $ WS.runServer (unpack $ c ^. #host) (c ^. #port) app--connect :: (MonadIO m, MonadConc m) => WS.PendingConnection -> Cont m WS.Connection-connect p = Cont $ \action ->- bracket- (liftIO $ WS.acceptRequest p)- (\conn -> liftIO $ WS.sendClose conn ("Bye from connect!" :: Text))- (\conn ->- C.withAsync- (liftIO $ forever $ WS.sendPing conn ("ping" :: ByteString) >> sleep 30)- (\_ -> action conn))--clientApp :: (MonadIO m, MonadConc m) =>- Box m (Either Text Text) Text ->- WS.Connection ->- m ()-clientApp (Box c e) conn =- void $- C.race- (receiver' c conn)- (sender (Box mempty e) conn)--responderApp ::- (Text -> Either Text Text) ->- WS.PendingConnection ->- IO ()-responderApp f p = with (connect p) (responder f mempty)--serverApp ::- (MonadConc m, MonadIO m) =>- Box m Text Text ->- WS.PendingConnection ->- m ()-serverApp (Box c e) p = void $ with (connect p)- (\conn -> C.race- (receiver c conn)- (sender (Box mempty e) conn))---- | default websocket receiver--- Lefts are info/debug-receiver' :: (MonadIO m) =>- Committer m (Either Text Text) ->- WS.Connection ->- m Bool-receiver' c conn = go- where- go = do- msg <- liftIO $ WS.receive conn- case msg of- WS.ControlMessage (WS.Close w b) ->- commit- c- ( Left- ( "receiver: received: close: " <> show w <> " " <> show b- )- )- WS.ControlMessage _ -> go- WS.DataMessage _ _ _ msg' -> do- commit c $ Left $ "receiver: received: " <> (WS.fromDataMessage msg' :: Text)- _ <- commit c (Right (WS.fromDataMessage msg'))- go---- | default websocket receiver--- Lefts are info/debug-receiver :: (MonadIO m) =>- Committer m Text ->- WS.Connection ->- m ()-receiver c conn = go- where- go = do- msg <- liftIO $ WS.receive conn- case msg of- WS.ControlMessage (WS.Close _ _) -> pure ()- WS.ControlMessage _ -> go- WS.DataMessage _ _ _ msg' -> commit c (WS.fromDataMessage msg') >> go---- | default websocket sender-sender ::- (MonadIO m, WS.WebSocketsData a, Show a) =>- Box m Text a ->- WS.Connection ->- m ()-sender (Box c e) conn = forever $ do- msg <- emit e- case msg of- Nothing -> pure ()- Just msg' -> do- commit c $ "sender: sending: " <> (show msg' :: Text)- liftIO $ WS.sendTextData conn msg'---- | a receiver that responds based on received Text.--- lefts are quit signals. Rights are response text.-responder :: (MonadIO m) =>- (Text -> Either Text Text) ->- Committer m Text ->- WS.Connection ->- m ()-responder f c conn = go- where- go = do- msg <- liftIO $ WS.receive conn- case msg of- WS.ControlMessage (WS.Close _ _) -> do- commit c "responder: normal close"- liftIO $ WS.sendClose conn ("received close signal: responder closed." :: Text)- WS.ControlMessage _ -> go- WS.DataMessage _ _ _ msg' -> do- case (f $ WS.fromDataMessage msg') of- Left _ -> do- commit c "responder: sender initiated close"- liftIO $ WS.sendClose conn ("received close signal: responder closed." :: Text)- Right r -> do- commit c ("responder: sending" <> r)- liftIO $ WS.sendTextData conn r- go
+ src/Box/Socket/Types.hs view
@@ -0,0 +1,14 @@+-- | Abstract sockets connected to 'Box'es.+module Box.Socket.Types+ ( PostSend (..),+ SocketStatus (..),+ )+where++import GHC.Generics (Generic)++-- | Whether to stay open after an emitter ends or send a close after a delay in seconds.+data PostSend = StayOpen | CloseAfter Double deriving (Generic, Eq, Show)++-- | Whether a socket remains open or closed after an action finishes.+data SocketStatus = SocketOpen | SocketClosed | SocketBroken deriving (Generic, Eq, Show)
+ src/Box/TCP.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE OverloadedStrings #-}++-- | TCP Boxes.+module Box.TCP+ ( TCPConfig (..),+ defaultTCPConfig,+ TCPEnv (..),+ Socket,+ connect,+ serve,+ receiver,+ sender,+ duplex,+ clientBox,+ clientCoBox,+ serverBox,+ serverCoBox,+ responseServer,+ )+where++import Box+import Box.Socket.Types+import Control.Concurrent.Async+import Control.Monad+import Data.ByteString (ByteString)+import Data.Text (Text, unpack)+import GHC.Generics (Generic)+import Network.Simple.TCP (Socket)+import Network.Simple.TCP qualified as NS++-- | TCP configuration+--+-- >>> defaultTCPConfig+-- TCPConfig {hostPreference = HostAny, host = "127.0.0.1", port = "3566", chunk = 2048, endLine = "\n"}+data TCPConfig = TCPConfig+ { hostPreference :: NS.HostPreference,+ host :: Text,+ port :: Text,+ chunk :: Int,+ endLine :: Text+ }+ deriving (Show, Eq, Generic)++-- | default+defaultTCPConfig :: TCPConfig+defaultTCPConfig = TCPConfig NS.HostAny "127.0.0.1" "3566" 2048 "\n"++-- | An active TCP environment+data TCPEnv = TCPEnv+ { socket :: NS.Socket,+ sockaddr :: NS.SockAddr+ }++-- | connect an action (ie a client)+connect :: TCPConfig -> Codensity IO TCPEnv+connect cfg =+ Codensity $+ NS.connect (unpack $ host cfg) (unpack $ port cfg)+ . (\action (s, a) -> action (TCPEnv s a))++-- | serve an action (ie a server)+serve :: TCPConfig -> Codensity IO TCPEnv+serve cfg =+ Codensity $+ NS.serve (hostPreference cfg) (unpack $ port cfg)+ . (\action (s, a) -> void $ action (TCPEnv s a))++-- | Commit received ByteStrings.+receiver ::+ TCPConfig ->+ Committer IO ByteString ->+ Socket ->+ IO ()+receiver cfg c conn = go+ where+ go = do+ msg <- NS.recv conn (chunk cfg)+ case msg of+ Nothing -> pure ()+ Just bs -> commit c bs >> go++-- | Send emitted ByteStrings.+sender ::+ Emitter IO ByteString ->+ Socket ->+ IO SocketStatus+sender e conn = go+ where+ go = do+ bs <- emit e+ case bs of+ Nothing -> pure SocketOpen+ Just bs' -> NS.send conn bs' >> go++-- | A two-way connection.+duplex ::+ TCPConfig ->+ PostSend ->+ Box IO ByteString ByteString ->+ Socket ->+ IO ()+duplex cfg ps (Box c e) conn = do+ _ <-+ race+ ( do+ status <- sender e conn+ case (ps, status) of+ (CloseAfter s, SocketOpen) -> sleep s+ _ -> pure ()+ )+ (receiver cfg c conn)+ pure ()++-- | A 'Box' action for a client.+clientBox ::+ TCPConfig ->+ PostSend ->+ Box IO ByteString ByteString ->+ IO ()+clientBox cfg ps b = duplex cfg ps b . socket <$|> connect cfg++-- | A client 'CoBox'.+clientCoBox ::+ TCPConfig ->+ PostSend ->+ CoBox IO ByteString ByteString+clientCoBox cfg ps = fromAction (clientBox cfg ps)++-- | A 'Box' action for a server.+serverBox ::+ TCPConfig ->+ PostSend ->+ Box IO ByteString ByteString ->+ IO ()+serverBox cfg ps b = duplex cfg ps b . socket <$|> serve cfg++-- | A server 'CoBox'.+serverCoBox ::+ TCPConfig ->+ PostSend ->+ CoBox IO ByteString ByteString+serverCoBox cfg ps = fromAction (serverBox cfg ps)++-- | A receiver that applies a response function to received ByteStrings.+responseServer :: TCPConfig -> (ByteString -> Maybe ByteString) -> IO ()+responseServer cfg f = fuse (pure . f) <$|> serverCoBox cfg (CloseAfter 0.5)
+ src/Box/TCP/Example.hs view
@@ -0,0 +1,83 @@+{-# LANGUAGE OverloadedStrings #-}++-- | It's a box. It's a TCP socket. It's an example.+module Box.TCP.Example where++import Box+import Box.Socket.Types+import Box.TCP+import Control.Concurrent.Async+import Data.ByteString+import Data.Profunctor+import Data.Text+import Data.Text.Encoding++-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import Box+-- >>> import Box.TCP.Example+-- >>> import Control.Concurrent.Async++-- | A server that only sends and a client that only receives.+--+-- The result here is indeterminate: it can return ["ab"] or ["a","b"] depending on when the client and servers fire.+--+-- > senderExample ["a","b"]+-- ["ab"]+senderExample :: [ByteString] -> IO [ByteString]+senderExample ts = do+ (c, r) <- refCommitter+ a <- async (serverBox defaultTCPConfig (CloseAfter 0.2) . Box mempty <$|> qList ts)+ sleep 0.2+ clientBox defaultTCPConfig (CloseAfter 0.5) (Box c mempty)+ sleep 0.6+ cancel a+ r++-- | A server that only sends and a client that only receives.+--+-- >>> senderLinesExample ["a","b"]+-- ["a","b"]+senderLinesExample :: [Text] -> IO [Text]+senderLinesExample ts = do+ (c, r) <- refCommitter+ a <- async (serverBox defaultTCPConfig (CloseAfter 0.2) . fromLineBox "\n" . Box mempty <$|> qList ts)+ sleep 0.2+ clientBox defaultTCPConfig (CloseAfter 0.5) (fromLineBox "\n" $ Box c mempty)+ sleep 0.6+ cancel a+ r++-- | echo server example+--+-- >>> echoExample ["a","b","c"]+-- ["echo: abc"]+echoExample :: [ByteString] -> IO [ByteString]+echoExample ts = do+ (c, r) <- refCommitter+ a <-+ async+ (responseServer defaultTCPConfig (pure . ("echo: " <>)))+ sleep 0.1+ clientBox defaultTCPConfig (CloseAfter 0.2) . Box c <$|> qList ts+ sleep 0.1+ cancel a+ r++-- | "q" to close the client, reads and writes from std+--+-- >> clientIO+-- *** Exception: Network.Socket.connect: <socket: ...>: does not exist (Connection refused)+clientIO :: IO ()+clientIO =+ clientBox defaultTCPConfig (CloseAfter 0) (dimap decodeUtf8 encodeUtf8 (stdBox "q"))++-- | "q" to close a client socket down. Ctrl-c to close the server. Reads and writes from std.+--+-- >> a <- async serverIO+-- >> serverIO+-- *** Exception: Network.Socket.bind: resource busy (Address already in use)+--+-- >> cancel a+serverIO :: IO ()+serverIO = serverBox defaultTCPConfig (CloseAfter 0) (dimap decodeUtf8 encodeUtf8 (stdBox "q"))
+ src/Box/Websocket.hs view
@@ -0,0 +1,238 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Websocket components built with 'Box'es.+module Box.Websocket+ ( SocketConfig (..),+ defaultSocketConfig,+ connect,+ serve,+ pending,+ serverApp,+ receiver,+ receiver_,+ sender,+ sender_,+ duplex,+ duplex_,+ clientBox,+ clientCoBox,+ serverBox,+ serverCoBox,+ responseServer,+ )+where++import Box+import Box.Socket.Types+import Control.Concurrent.Async+import Control.Exception+import Control.Monad+import Data.ByteString qualified as BS+import Data.Functor.Contravariant+import Data.Text (Text, pack, unpack)+import GHC.Generics (Generic)+import Network.WebSockets++-- | Socket configuration+--+-- >>> defaultSocketConfig+-- SocketConfig {host = "127.0.0.1", port = 9160, path = "/"}+data SocketConfig = SocketConfig+ { host :: Text,+ port :: Int,+ path :: Text+ }+ deriving (Show, Eq, Generic)++-- | official default+defaultSocketConfig :: SocketConfig+defaultSocketConfig = SocketConfig "127.0.0.1" 9160 "/"++-- | connect an action (ie a client)+connect :: SocketConfig -> Codensity IO Connection+connect c = Codensity $ \action ->+ runClient (unpack $ host c) (port c) (unpack $ path c) action++-- | serve an action (ie a server)+serve :: SocketConfig -> Codensity IO Connection+serve c =+ Codensity $+ runServerWithOptions (defaultServerOptions {serverHost = unpack (host c), serverPort = port c}) . upgrade+ where+ upgrade action p = void $ action <$|> pending p++-- | Attach a box to a 'PendingConnection' in wai-style.+serverApp ::+ Box IO Text Text ->+ PendingConnection ->+ IO ()+serverApp b p = upgrade (duplex (CloseAfter 0.2) b) p+ where+ upgrade action p' = void $ action <$|> pending p'++-- | Given a 'PendingConnection', provide a 'Connection' continuation.+pending :: PendingConnection -> Codensity IO Connection+pending p = Codensity $ \action ->+ bracket+ (acceptRequest p)+ (\_ -> pure ())+ ( \conn ->+ withAsync+ (forever $ sendPing conn ("connect ping" :: BS.ByteString) >> sleep 30)+ (\_ -> action conn)+ )++-- | Commit received messages, finalising on receiving a 'CloseRequest'+receiver ::+ (WebSocketsData a) =>+ Committer IO a ->+ Connection ->+ IO ()+receiver c conn = go+ where+ go = do+ msg <- try (receiveData conn)+ case msg of+ Left (CloseRequest _ _) -> pure ()+ Left err -> throwIO err+ Right msg' -> commit c msg' >> go++-- | Commit received messages, finalising on receiving a 'CloseRequest', with event logging.+receiver_ ::+ (WebSocketsData a, Show a) =>+ Committer IO a ->+ Committer IO Text ->+ Connection ->+ IO ()+receiver_ c cLog conn = go+ where+ go = do+ msg <- try (receiveData conn)+ _ <- commit cLog ("receiveData:" <> pack (show msg))+ case msg of+ Left (CloseRequest _ _) -> pure ()+ Left err -> throwIO err+ Right msg' -> commit c msg' >> go++-- | Send emitted messages, returning whether the socket remained open (the 'Emitter' ran out of emits) or closed (a 'CloseRequest' was received).+sender ::+ (WebSocketsData a) =>+ Emitter IO a ->+ Connection ->+ IO SocketStatus+sender e conn = go+ where+ go = do+ msg <- emit e+ case msg of+ Nothing -> pure SocketOpen+ Just msg' -> do+ ok <- try (sendTextData conn msg')+ case ok of+ Left (CloseRequest _ _) -> pure SocketClosed+ Left err -> throwIO err+ Right () -> go++-- | Send emitted messages, returning whether the socket remained open (the 'Emitter' ran out of emits) or closed (a 'CloseRequest' was received). With event logging.+sender_ ::+ (WebSocketsData a, Show a) =>+ Emitter IO a ->+ Committer IO Text ->+ Connection ->+ IO SocketStatus+sender_ e cLog conn = go+ where+ go = do+ msg <- emit e+ _ <- commit cLog ("emit:" <> pack (show msg))+ case msg of+ Nothing -> pure SocketOpen+ Just msg' -> do+ ok <- try (sendTextData conn msg')+ _ <- commit cLog ("sendTextData:" <> pack (show ok))+ case ok of+ Left (CloseRequest _ _) -> pure SocketClosed+ Left err -> throwIO err+ Right () -> go++-- | A two-way connection. Closes if it receives a 'CloseRequest' exception, or if 'PostSend' is 'CloseAfter'.+duplex ::+ (WebSocketsData a) =>+ PostSend ->+ Box IO a a ->+ Connection ->+ IO ()+duplex ps (Box c e) conn = do+ concurrentlyRight+ ( do+ status <- sender e conn+ case (ps, status) of+ (CloseAfter s, SocketOpen) -> do+ sleep s+ sendClose conn ("close after sending" :: Text)+ _ -> pure ()+ )+ (receiver c conn)++-- | A two-way connection. Closes if it receives a 'CloseRequest' exception, or if 'PostSend' is 'CloseAfter'. With event logging.+duplex_ ::+ (WebSocketsData a, Show a) =>+ PostSend ->+ Committer IO Text ->+ Box IO a a ->+ Connection ->+ IO ()+duplex_ ps cLog (Box c e) conn = do+ concurrentlyRight+ ( do+ status <- sender_ e (contramap ("sender_:" <>) cLog) conn+ _ <- commit cLog ("sender_ closed with " <> pack (show status))+ case (ps, status) of+ (CloseAfter s, SocketOpen) -> do+ sleep s+ sendClose conn ("close after sending" :: Text)+ _ -> pure ()+ )+ ( do+ receiver_ c (contramap ("receiver_:" <>) cLog) conn+ void $ commit cLog "receiver_ closed"+ )+ void $ commit cLog "duplex_ closed"++-- | A 'Box' action for a client.+clientBox ::+ (WebSocketsData a) =>+ SocketConfig ->+ PostSend ->+ Box IO a a ->+ IO ()+clientBox cfg ps b = duplex ps b <$|> connect cfg++-- | A client 'CoBox'.+clientCoBox ::+ (WebSocketsData a) =>+ SocketConfig ->+ PostSend ->+ CoBox IO a a+clientCoBox cfg ps = fromAction (clientBox cfg ps)++-- | A 'Box' action for a server.+serverBox ::+ (WebSocketsData a) =>+ SocketConfig ->+ PostSend ->+ Box IO a a ->+ IO ()+serverBox cfg ps b = duplex ps b <$|> serve cfg++-- | A server 'CoBox'.+serverCoBox ::+ (WebSocketsData a) =>+ SocketConfig ->+ PostSend ->+ CoBox IO a a+serverCoBox cfg ps = fromAction (serverBox cfg ps)++-- | A receiver that applies a response function to received messages.+responseServer :: (WebSocketsData a) => SocketConfig -> (a -> Maybe a) -> IO ()+responseServer cfg f = fuse (pure . f) <$|> serverCoBox cfg (CloseAfter 0.5)
+ src/Box/Websocket/Example.hs view
@@ -0,0 +1,86 @@+{-# LANGUAGE OverloadedStrings #-}++-- | It's a box. It's a websocket. It's an example.+module Box.Websocket.Example where++import Box+import Box.Socket.Types+import Box.Websocket+import Control.Concurrent.Async+import Data.Functor.Contravariant+import Data.Text (Text)++-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import Box+-- >>> import Box.Websocket.Example+-- >>> import Control.Concurrent.Async++-- | A server that only sends and a client that only receives.+--+-- >>> senderExample ["a","b"]+-- ["a","b"]+senderExample :: [Text] -> IO [Text]+senderExample ts = do+ (c, r) <- refCommitter+ a <- async (serverBox defaultSocketConfig (CloseAfter 0.2) . Box mempty <$|> qList ts)+ sleep 0.1+ clientBox defaultSocketConfig StayOpen (Box c mempty)+ sleep 0.1+ cancel a+ r++-- | echo server example+--+-- >>> echoExample ["a","b","c"]+-- ["echo: a","echo: b","echo: c"]+echoExample :: [Text] -> IO [Text]+echoExample ts = do+ (c, r) <- refCommitter+ a <-+ async+ (responseServer defaultSocketConfig (pure . (("echo: " :: Text) <>)))+ sleep 0.1+ clientBox defaultSocketConfig (CloseAfter 0.2) . Box c <$|> qList ts+ sleep 0.1+ cancel a+ r++-- | echo server example, with event logging.+--+-- The order of events is non-deterministic, so this is a rough guide:+--+-- > echoLogExample ["a","b","c"]+-- (["echo: a","echo: b","echo: c"],["client:sender_:emit:Just \"a\"","client:sender_:sendTextData:Right ()","client:sender_:emit:Just \"b\"","client:sender_:sendTextData:Right ()","client:sender_:emit:Just \"c\"","client:sender_:sendTextData:Right ()","client:sender_:emit:Nothing","client:sender_ closed with SocketOpen","server:receiver_:receiveData:Right \"a\"","server:receiver_:receiveData:Right \"b\"","server:receiver_:receiveData:Right \"c\"","server:sender_:emit:Just \"echo: a\"","server:sender_:sendTextData:Right ()","server:sender_:emit:Just \"echo: b\"","server:sender_:sendTextData:Right ()","server:sender_:emit:Just \"echo: c\"","server:sender_:sendTextData:Right ()","client:receiver_:receiveData:Right \"echo: a\"","client:receiver_:receiveData:Right \"echo: b\"","client:receiver_:receiveData:Right \"echo: c\"","server:receiver_:receiveData:Left (CloseRequest 1000 \"close after sending\")","server:receiver_ closed","client:receiver_:receiveData:Left (CloseRequest 1000 \"close after sending\")","client:receiver_ closed","client:duplex_ closed","server:duplex_ closed"])+echoLogExample :: [Text] -> IO ([Text], [Text])+echoLogExample ts = do+ (c, r) <- refCommitter+ (cLog, resLog) <- refCommitter+ a <-+ async+ (fuse (pure . pure . (("echo: " :: Text) <>)) <$|> fromAction (\b -> duplex_ (CloseAfter 0.5) (contramap ("server:" <>) cLog) b <$|> serve defaultSocketConfig))+ sleep 0.1+ duplex_ (CloseAfter 0.2) (contramap ("client:" <>) cLog) . Box c <$> qList ts <*|> connect defaultSocketConfig+ sleep 0.1+ cancel a+ (,) <$> r <*> resLog++-- | "q" to close the client, reads and writes from std+--+-- >> clientIO+-- *** Exception: Network.Socket.connect: <socket: ...>: does not exist (Connection refused)+-- ...+clientIO :: IO ()+clientIO =+ clientBox defaultSocketConfig (CloseAfter 0) (stdBox "q")++-- | "q" to close a client socket down. Ctrl-c to close the server. Reads and writes from std.+--+-- >> a <- async serverIO+-- >> serverIO+-- *** Exception: Network.Socket.bind: resource busy (Address already in use)+-- ...+--+-- >> cancel a+serverIO :: IO ()+serverIO = serverBox defaultSocketConfig (CloseAfter 0) (stdBox "q")
− test/test.hs
@@ -1,12 +0,0 @@-{-# LANGUAGE NoImplicitPrelude #-}-{-# OPTIONS_GHC -Wall #-}--module Main where--import NumHask.Prelude-import Test.DocTest--main :: IO ()-main = doctest- [ "app/box-socket.hs"- ]