ghci-websockets 0.0.1 → 0.0.2
raw patch · 12 files changed
+278/−191 lines, 12 filesdep +bytestringdep +waidep +wai-app-staticPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies added: bytestring, wai, wai-app-static, warp
API changes (from Hackage documentation)
- Ghci.Websockets: Config :: Int -> Verbosity -> Config
- Ghci.Websockets: Silent :: Verbosity
- Ghci.Websockets: Verbose :: Verbosity
- Ghci.Websockets: [port] :: Config -> Int
- Ghci.Websockets: [verbosity] :: Config -> Verbosity
- Ghci.Websockets: broadcast :: ToJSON a => a -> IO ()
- Ghci.Websockets: data Config
- Ghci.Websockets: data Verbosity
- Ghci.Websockets: defaultConfig :: Config
- Ghci.Websockets: initialise :: Config -> IO ()
- Ghci.Websockets: initialiseDef :: IO ()
- Ghci.Websockets: instance GHC.Classes.Eq Ghci.Websockets.Verbosity
- Ghci.Websockets: instance GHC.Classes.Ord Ghci.Websockets.Verbosity
- Ghci.Websockets: instance GHC.Show.Show Ghci.Websockets.Verbosity
- Ghci.Websockets.Simple: MsgHtml :: Text -> Message
- Ghci.Websockets.Simple: MsgPlotly :: [Value] -> Value -> Message
- Ghci.Websockets.Simple: MsgText :: Text -> Message
- Ghci.Websockets.Simple: broadcastHtml :: Text -> IO ()
- Ghci.Websockets.Simple: broadcastPlot :: [(Double, Double)] -> IO ()
- Ghci.Websockets.Simple: broadcastText :: Text -> IO ()
- Ghci.Websockets.Simple: data Message
- Ghci.Websockets.Simple: instance Data.Aeson.Types.ToJSON.ToJSON Ghci.Websockets.Simple.Message
+ Ghci.Server: Silent :: Verbosity
+ Ghci.Server: Verbose :: Verbosity
+ Ghci.Server: cfHTTPPort :: Config -> Int
+ Ghci.Server: cfVerbosity :: Config -> Verbosity
+ Ghci.Server: cfWSPort :: Config -> Int
+ Ghci.Server: data Config
+ Ghci.Server: data Verbosity
+ Ghci.Server: defaultConfig :: Config
+ Ghci.Server: sendHtml :: Text -> IO ()
+ Ghci.Server: sendPlot :: [(Double, Double)] -> IO ()
+ Ghci.Server: sendText :: Text -> IO ()
+ Ghci.Server: start :: IO ()
+ Ghci.Server: startConfig :: Config -> IO ()
+ Ghci.Server.Config: Silent :: Verbosity
+ Ghci.Server.Config: Verbose :: Verbosity
+ Ghci.Server.Config: cfHTTPPort :: Config -> Int
+ Ghci.Server.Config: cfVerbosity :: Config -> Verbosity
+ Ghci.Server.Config: cfWSPort :: Config -> Int
+ Ghci.Server.Config: data Config
+ Ghci.Server.Config: data Verbosity
+ Ghci.Server.Config: defaultConfig :: Config
+ Ghci.Server.Config: instance GHC.Classes.Eq Ghci.Server.Config.Verbosity
+ Ghci.Server.Config: instance GHC.Classes.Ord Ghci.Server.Config.Verbosity
+ Ghci.Server.Config: instance GHC.Show.Show Ghci.Server.Config.Verbosity
+ Ghci.Server.Config: logStr :: Config -> String -> IO ()
+ Ghci.Server.Http.Internal: startConfig :: Config -> IO ()
+ Ghci.Server.Http.Internal: theApp :: Application
+ Ghci.Server.Http.Stage0: mkEmbedded :: IO [EmbeddableEntry]
+ Ghci.Server.Websockets.Internal: ServerState :: Map ConnectionID Connection -> ServerState
+ Ghci.Server.Websockets.Internal: [unServerState] :: ServerState -> Map ConnectionID Connection
+ Ghci.Server.Websockets.Internal: addConnection :: Connection -> ServerState -> (ServerState, ConnectionID)
+ Ghci.Server.Websockets.Internal: application :: Config -> ServerApp
+ Ghci.Server.Websockets.Internal: closeConnection :: Config -> ConnectionID -> ConnectionException -> IO ()
+ Ghci.Server.Websockets.Internal: deleteConnection :: ConnectionID -> ServerState -> ServerState
+ Ghci.Server.Websockets.Internal: newtype ServerState
+ Ghci.Server.Websockets.Internal: send :: ToJSON a => a -> IO ()
+ Ghci.Server.Websockets.Internal: serverState :: ServerState
+ Ghci.Server.Websockets.Internal: startConfig :: Config -> IO ()
+ Ghci.Server.Websockets.Internal: theStore :: Store (MVar ServerState)
+ Ghci.Server.Websockets.Internal: type ConnectionID = Int
+ Ghci.Server.Websockets.Message: MsgHtml :: Text -> Message
+ Ghci.Server.Websockets.Message: MsgPlotly :: [Value] -> Value -> Message
+ Ghci.Server.Websockets.Message: MsgText :: Text -> Message
+ Ghci.Server.Websockets.Message: data Message
+ Ghci.Server.Websockets.Message: instance Data.Aeson.Types.ToJSON.ToJSON Ghci.Server.Websockets.Message.Message
+ Ghci.Server.Websockets.Message: sendHtml :: Text -> IO ()
+ Ghci.Server.Websockets.Message: sendPlot :: [(Double, Double)] -> IO ()
+ Ghci.Server.Websockets.Message: sendText :: Text -> IO ()
Files
- CHANGELOG.md +7/−1
- README.md +4/−4
- ghci-websockets.cabal +12/−6
- ghci-websockets.gif binary
- src/Ghci/Server.hs +40/−0
- src/Ghci/Server/Config.hs +34/−0
- src/Ghci/Server/Http/Internal.hs +27/−0
- src/Ghci/Server/Http/Stage0.hs +16/−0
- src/Ghci/Server/Websockets/Internal.hs +66/−0
- src/Ghci/Server/Websockets/Message.hs +72/−0
- src/Ghci/Websockets.hs +0/−106
- src/Ghci/Websockets/Simple.hs +0/−74
CHANGELOG.md view
@@ -1,5 +1,11 @@ # Changelog -# 2019-07-14+# v0.0.2 (2019-07-16)++* Rename `broadcast` -> `send`+* Better module layout+* Server the index.html page over HTTP++# v0.0.1 (2019-07-14) * Initial release
README.md view
@@ -6,15 +6,15 @@ ## Contents -The `Ghci.Websockets` module implements the actual websocket server, broadcasting JSON objects to all clients. `Ghci.Websockets.Simple` adds a custom message type for text, HTML, and plots on top of that.+The `Ghci.Server.Websockets` module implements the actual websocket server, sending JSON objects to all clients. `Ghci.Server.Http` servers the index.html page that goes with the messages defined in `Ghci.Websockets.Message`. ## Quickstart * Add `ghci-websockets` to the `build-depends` field of your .cabal file * Run `cabal new-repl`-* Run `Ghci.Websockets.Simple.initialiseDef`-* Open `html/index.html` in a browser-* In GHCi, run `Ghci.Websockets.Simple.broadcastText "hello"` (see also `broadcastHtml` and `broadcastPlot` from the same module). You may need `:set -XOverloadedStrings`.+* Run `Ghci.Server.start`+* Open `localhost:3000` in a browser+* In GHCi, run `Ghci.Server.sendText "hello"` (see also `sendHtml` and `sendPlot` from the same module). You may need `:set -XOverloadedStrings`. ## Warning
ghci-websockets.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.2 name: ghci-websockets-version: 0.0.1+version: 0.0.2 license: BSD-3-Clause license-file: LICENSE copyright: Copyright (C) 2019 Jann Mueller@@ -26,8 +26,12 @@ library exposed-modules:- Ghci.Websockets- Ghci.Websockets.Simple+ Ghci.Server.Http.Internal+ Ghci.Server.Http.Stage0+ Ghci.Server.Websockets.Internal+ Ghci.Server.Websockets.Message+ Ghci.Server.Config+ Ghci.Server hs-source-dirs: src default-language: Haskell2010 ghc-options: -Wall@@ -37,6 +41,8 @@ foreign-store -any, websockets -any, text -any,- containers -any--+ containers -any,+ wai-app-static -any,+ bytestring -any,+ wai -any,+ warp -any
ghci-websockets.gif view
binary file changed (216534 → 89616 bytes)
+ src/Ghci/Server.hs view
@@ -0,0 +1,40 @@+module Ghci.Server(+ -- $docs+ -- * Starting the server+ start+ , startConfig+ -- * Sending messages+ , sendText+ , sendHtml+ , sendPlot+ -- * Configuration+ , Config+ , Verbosity(..)+ , defaultConfig+ , cfHTTPPort+ , cfVerbosity+ , cfWSPort+ ) where++import Ghci.Server.Config (Config, Verbosity (..),+ cfHTTPPort, cfVerbosity,+ cfWSPort, defaultConfig)+import qualified Ghci.Server.Http.Internal as HTTP+import qualified Ghci.Server.Websockets.Internal as WS+import Ghci.Server.Websockets.Message (sendHtml, sendPlot,+ sendText)++-- $docs+-- This modules implements a websocket server whose state survives GHCi+-- reloads. To use it, run 'start' once per GHCi session, and then+-- use 'sendText', 'sendHtml' and 'sendPlot' to show the+-- values on all clients that are currently connected.++-- | Start the websocket and HTTP servers using the config+startConfig :: Config -> IO ()+startConfig = (<>) <$> HTTP.startConfig <*> WS.startConfig++-- | Start the server with default settings (HTTP on port 3000, websockets+-- on port 9160)+start :: IO ()+start = startConfig defaultConfig
+ src/Ghci/Server/Config.hs view
@@ -0,0 +1,34 @@+module Ghci.Server.Config(+ Config+ , defaultConfig+ , cfWSPort+ , cfHTTPPort+ , cfVerbosity+ , Verbosity(..)+ , logStr+ ) where++-- | What to do with log messages+data Verbosity =+ Verbose -- ^ Write all log messages to stdout+ | Silent -- ^ Ignore all log messages+ deriving (Eq, Ord, Show)++-- | Server configuration+data Config =+ Config+ { cfWSPort :: Int -- ^ Websocket port+ , cfHTTPPort :: Int -- ^ HTTP port+ , cfVerbosity :: Verbosity -- ^ What to do with log messages+ }++-- | Log a message according to the configured 'Verbosity'+logStr :: Config -> String -> IO ()+logStr c s = case cfVerbosity c of+ Silent -> pure ()+ Verbose -> putStrLn s++-- | Default config, use ports 9160 (websockets) and 3000 (http) and ignore all+-- log messages.+defaultConfig :: Config+defaultConfig = Config 9160 3000 Silent
+ src/Ghci/Server/Http/Internal.hs view
@@ -0,0 +1,27 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}+-- | A simple HTTP server that serves the index.html page+module Ghci.Server.Http.Internal where++import Control.Concurrent (forkIO)+import Control.Monad (void)+import Ghci.Server.Http.Stage0 (mkEmbedded)+import Network.Wai (Application)+import Network.Wai.Application.Static (ssIndices, ssRedirectToIndex,+ staticApp)+import Network.Wai.Handler.Warp (run)+import WaiAppStatic.Storage.Embedded+import WaiAppStatic.Types (unsafeToPiece)++import Ghci.Server.Config (Config, cfHTTPPort)++theApp :: Application+theApp =+ let st = $(mkSettings mkEmbedded) in+ staticApp $ st { ssRedirectToIndex = True, ssIndices = [unsafeToPiece "index.html"] }++startConfig :: Config -> IO ()+startConfig cf = do+ let p = cfHTTPPort cf+ putStrLn $ "Starting HTTP server on port " ++ show p+ void $ forkIO $ run (cfHTTPPort cf) theApp
+ src/Ghci/Server/Http/Stage0.hs view
@@ -0,0 +1,16 @@+{-# LANGUAGE OverloadedStrings #-}+module Ghci.Server.Http.Stage0 (mkEmbedded) where++import qualified Data.ByteString.Lazy as BL+import WaiAppStatic.Storage.Embedded++mkEmbedded :: IO [EmbeddableEntry]+mkEmbedded = do+ file <- BL.readFile "html/index.html"+ let emb = EmbeddableEntry {+ eLocation = "index.html"+ , eMimeType = "text/html"+ , eContent = Left (mempty, file)+ }++ return [emb]
+ src/Ghci/Server/Websockets/Internal.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE OverloadedStrings #-}+module Ghci.Server.Websockets.Internal where++import Control.Concurrent (MVar, forkIO, modifyMVar, modifyMVar_,+ newMVar, readMVar)+import Control.Exception (catch)+import Control.Monad (void, (>=>))+import Data.Aeson (ToJSON)+import qualified Data.Aeson as Aeson+import Data.Foldable (traverse_)+import qualified Data.Map as Map+import qualified Data.Text as Text+import qualified Foreign.Store as Store+import qualified Network.WebSockets as WS++import Ghci.Server.Config (Config, cfWSPort, logStr)++newtype ServerState = ServerState { unServerState :: Map.Map ConnectionID WS.Connection }++type ConnectionID = Int++serverState :: ServerState+serverState = ServerState Map.empty++addConnection :: WS.Connection -> ServerState -> (ServerState, ConnectionID)+addConnection c (ServerState mp) = (ServerState (Map.insert k c mp), k) where+ k = maybe 0 (succ . fst) $ Map.lookupMax mp++deleteConnection :: ConnectionID -> ServerState -> ServerState+deleteConnection i (ServerState mp) = ServerState (Map.delete i mp)++theStore :: Store.Store (MVar ServerState)+theStore = Store.Store 0++-- | Send a JSON object to all clients. Throws an exception if 'initialise' has+-- not been run first.+send :: ToJSON a => a -> IO ()+send t = Store.withStore theStore (readMVar >=> go) where+ go s = traverse_ (`WS.sendTextData` msg) (unServerState s)+ msg = Aeson.encode t++-- | Start the websocket server using the port specified in the config. Call+-- once per GHCi session.+startConfig :: Config -> IO ()+startConfig c = do+ let p = cfWSPort c+ state <- newMVar serverState+ Store.writeStore theStore state+ logStr c ("Starting websocket server on port " ++ show p)+ void $ forkIO (WS.runServer "127.0.0.1" p (application c))++application :: Config -> WS.ServerApp+application conf pending = do+ state <- Store.readStore theStore+ conn <- WS.acceptRequest pending+ WS.forkPingThread conn 30+ connID <- modifyMVar state (pure . addConnection conn)+ logStr conf $ "Accepted connection " ++ show connID+ let go = (WS.receiveData conn >>= logStr conf . Text.unpack) >> go+ catch go (closeConnection conf connID)++closeConnection :: Config -> ConnectionID -> WS.ConnectionException -> IO ()+closeConnection conf connID ex = do+ logStr conf $ "Closing connection " ++ show connID ++ " due to " ++ show ex+ state <- Store.readStore theStore+ modifyMVar_ state (pure . deleteConnection connID)
+ src/Ghci/Server/Websockets/Message.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+module Ghci.Server.Websockets.Message (+ -- $docs+ sendText+ , sendHtml+ , sendPlot+ , Message(..)+ ) where++import Data.Aeson+import Data.Text (Text)+import qualified Data.Text as Text++import Ghci.Server.Websockets.Internal (send)++-- $docs+-- This module provides the 'Message' data type, and various constructors for+-- it. It is intended to be used together with the 'html/index.html' file.+--+-- = Usage+--+-- 1. Start a GHCi session and run 'Ghci.Websockets.initialiseDef'+-- 2. Open @html/index.html@ in a browser (it's self-contained, no http server+-- required)+-- 3. Use 'sendText', 'sendHtml' and 'sendPlot' to show things+-- in the browser window.++data Message =+ MsgText Text.Text -- ^ A string+ | MsgHtml Text.Text -- ^ An HTML fragment+ | MsgPlotly [Value] Value+ -- ^ The 'data' and 'layout' parameters used for 'Plotly.newPlot'+ -- (see+ -- https://plot.ly/javascript/plotlyjs-function-reference/#plotlynewplot).+ -- The first parameter is a list of Plotly traces (See+ -- https://plot.ly/javascript/reference/,+ -- "Trace types"), the second parameter is a Plotly layout value (see+ -- https://plot.ly/javascript/reference/, "Layout")++instance ToJSON Message where+ toJSON = \case+ MsgText t -> object ["tag" .= ("text" :: String), "contents" .= t]+ MsgHtml t -> object ["tag" .= ("html" :: String), "contents" .= t]+ MsgPlotly dt ly -> object ["tag" .= ("plot" :: String), "contents" .= object ["data" .= dt, "layout" .= ly]]++-- | Show a string.+--+-- >>> sendText "hello"+--+sendText :: Text -> IO ()+sendText = send . MsgText++-- | Insert some HTML into the DOM.+--+-- >>> sendHtml "<h1>Hello</h1>"+--+sendHtml :: Text -> IO ()+sendHtml = send . MsgHtml++-- | Show a Plotly 2D line plot of the given points.+--+-- >>> sendPlot [(1, 2), (2, 5), (3, 4), (4, 3)]+--+-- >>> sendPlot $ fmap (\i -> let i' = (fromIntegral i / 10) in (i', sin i')) [1..100]+--+sendPlot :: [(Double, Double)] -> IO ()+sendPlot ns = send (MsgPlotly [dt] ly) where+ ly = object ["margin" .= object ["t" .= (0 :: Int)]]+ dt =+ let (xs, ys) = unzip ns in+ object [ "x" .= xs, "y" .= ys ]
− src/Ghci/Websockets.hs
@@ -1,106 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Ghci.Websockets(- -- $docs- --- initialise- , initialiseDef- , broadcast- -- * Configuration- , Config(..)- , Verbosity(..)- , defaultConfig- ) where--import Control.Concurrent (MVar, forkIO, modifyMVar, modifyMVar_,- newMVar, readMVar)-import Control.Exception (catch)-import Control.Monad (void, (>=>))-import Data.Aeson (ToJSON)-import qualified Data.Aeson as Aeson-import Data.Foldable (traverse_)-import qualified Data.Map as Map-import qualified Data.Text as Text-import qualified Foreign.Store as Store-import qualified Network.WebSockets as WS---- $docs--- This modules implements a websocket server whose state survives GHCi --- reloads. To use it, run 'initialiseDef' once per GHCi session, and then --- call 'broadcast' to send a JSON value to all clients that are currently --- connected. All messages from clients are ignored.---- | What to do with log messages-data Verbosity =- Verbose -- ^ Write all log messages to stdout- | Silent -- ^ Ignore all log messages- deriving (Eq, Ord, Show)---- | Server configuration-data Config =- Config- { port :: Int -- ^ What port to start the server on- , verbosity :: Verbosity -- ^ What to do with log messages- }--logStr :: Config -> String -> IO ()-logStr c s = case verbosity c of- Silent -> pure ()- Verbose -> putStrLn s---- | Default config, use port 9160 and ignore all log messages.-defaultConfig :: Config-defaultConfig = Config 9160 Silent--newtype ServerState = ServerState { unServerState :: Map.Map ConnectionID WS.Connection }--type ConnectionID = Int--serverState :: ServerState-serverState = ServerState Map.empty--addConnection :: WS.Connection -> ServerState -> (ServerState, ConnectionID)-addConnection c (ServerState mp) = (ServerState (Map.insert k c mp), k) where- k = maybe 0 (succ . fst) $ Map.lookupMax mp--deleteConnection :: ConnectionID -> ServerState -> ServerState-deleteConnection i (ServerState mp) = ServerState (Map.delete i mp)--theStore :: Store.Store (MVar ServerState)-theStore = Store.Store 0---- | Send a JSON object to all clients. Throws an exception if 'initialise' has--- not been run first.-broadcast :: ToJSON a => a -> IO ()-broadcast t = Store.withStore theStore (readMVar >=> go) where- go s = traverse_ (`WS.sendTextData` msg) (unServerState s)- msg = Aeson.encode t---- | Start the websocket server using the default config (port 9160). Call once--- per GHCi session.-initialiseDef :: IO ()-initialiseDef = initialise defaultConfig---- | Start the websocket server using the port specified in the config. Call--- once per GHCi session.-initialise :: Config -> IO ()-initialise c@Config{port=p} = do- state <- newMVar serverState- Store.writeStore theStore state- logStr c ("Starting websocket server on port " ++ show p)- void $ forkIO (WS.runServer "127.0.0.1" p (application c))--application :: Config -> WS.ServerApp-application conf pending = do- state <- Store.readStore theStore- conn <- WS.acceptRequest pending- WS.forkPingThread conn 30- connID <- modifyMVar state (pure . addConnection conn)- logStr conf $ "Accepted connection " ++ show connID- let go = (WS.receiveData conn >>= logStr conf . Text.unpack) >> go- catch go (closeConnection conf connID)--closeConnection :: Config -> ConnectionID -> WS.ConnectionException -> IO ()-closeConnection conf connID ex = do- logStr conf $ "Closing connection " ++ show connID ++ " due to " ++ show ex- state <- Store.readStore theStore- modifyMVar_ state (pure . deleteConnection connID)
− src/Ghci/Websockets/Simple.hs
@@ -1,74 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedStrings #-}-module Ghci.Websockets.Simple (- -- $docs- broadcastText- , broadcastHtml- , broadcastPlot- -- * Re-exports etc.- , Message(..)- , module Websockets- ) where--import Data.Aeson-import Data.Text (Text)-import qualified Data.Text as Text--import Ghci.Websockets as Websockets---- $docs--- This module provides the 'Message' data type, and various constructors for--- it. It is intended to be used together with the 'html/index.html' file.------ = Usage------ 1. Start a GHCi session and run 'Ghci.Websockets.initialiseDef'--- 2. Open @html/index.html@ in a browser (it's self-contained, no http server--- required)--- 3. Use 'broadcastText', 'broadcastHtml' and 'broadcastPlot' to show things--- in the browser window.--data Message =- MsgText Text.Text -- ^ A string- | MsgHtml Text.Text -- ^ An HTML fragment- | MsgPlotly [Value] Value- -- ^ The 'data' and 'layout' parameters used for 'Plotly.newPlot'- -- (see- -- https://plot.ly/javascript/plotlyjs-function-reference/#plotlynewplot).- -- The first parameter is a list of Plotly traces (See- -- https://plot.ly/javascript/reference/,- -- "Trace types"), the second parameter is a Plotly layout value (see- -- https://plot.ly/javascript/reference/, "Layout")--instance ToJSON Message where- toJSON = \case- MsgText t -> object ["tag" .= ("text" :: String), "contents" .= t]- MsgHtml t -> object ["tag" .= ("html" :: String), "contents" .= t]- MsgPlotly dt ly -> object ["tag" .= ("plot" :: String), "contents" .= object ["data" .= dt, "layout" .= ly]]---- | Show a string.------ >>> broadcastText "hello"----broadcastText :: Text -> IO ()-broadcastText = broadcast . MsgText---- | Insert some HTML into the DOM.------ >>> broadcastHtml "<h1>Hello</h1>"----broadcastHtml :: Text -> IO ()-broadcastHtml = broadcast . MsgHtml---- | Show a Plotly 2D line plot of the given points.------ >>> broadcastPlot [(1, 2), (2, 5), (3, 4), (4, 3)]------ >>> broadcastPlot $ fmap (\i -> let i' = (fromIntegral i / 10) in (i', sin i')) [1..100]----broadcastPlot :: [(Double, Double)] -> IO ()-broadcastPlot ns = broadcast (MsgPlotly [dt] ly) where- ly = object ["margin" .= object ["t" .= (0 :: Int)]]- dt =- let (xs, ys) = unzip ns in- object [ "x" .= xs, "y" .= ys ]