threepenny-gui-0.9.4.2: src/Foreign/JavaScript/Server.hs
{-# LANGUAGE RecordWildCards, OverloadedStrings #-}
module Foreign.JavaScript.Server (
httpComm, loadFile, loadDirectory,
) where
-- import general libraries
import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM as STM
import qualified Control.Exception as E
import Control.Monad
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Map as M
import Data.Text (Text)
import qualified Safe as Safe
import System.Environment
import System.FilePath
-- import web libraries
import qualified Data.Aeson as JSON
import qualified Network.WebSockets as WS
import qualified Network.WebSockets.Snap as WS
import Snap.Core as Snap hiding (path, dir)
import qualified Snap.Http.Server as Snap
import Snap.Util.FileServe
-- import internal modules
import Foreign.JavaScript.Resources
import Foreign.JavaScript.Types
{-----------------------------------------------------------------------------
HTTP Server using WebSockets
------------------------------------------------------------------------------}
-- | Run a HTTP server that creates a 'Comm' channel.
httpComm :: Config -> EventLoop -> IO ()
httpComm Config{..} worker = do
env <- getEnvironment
let config = Snap.setErrorLog (Snap.ConfigIoLog jsLog)
$ Snap.setAccessLog (Snap.ConfigIoLog jsLog)
$ maybe (configureHTTP env) configureSSL jsUseSSL
$ Snap.defaultConfig
server <- Server <$> newMVar newFilepaths <*> newMVar newFilepaths <*> return jsLog
Snap.httpServe config . route $
routeResources server jsCustomHTML jsStatic
++ routeWebsockets (worker server)
where
configureHTTP :: [(String, String)] -> Snap.Config m a -> Snap.Config m a
configureHTTP env config =
let portEnv = Safe.readMay =<< Prelude.lookup "PORT" env
addrEnv = fmap BS.pack $ Prelude.lookup "ADDR" env
in Snap.setPort (maybe defaultPort id (jsPort `mplus` portEnv))
$ Snap.setBind (maybe defaultAddr id (jsAddr `mplus` addrEnv)) config
configureSSL :: ConfigSSL -> Snap.Config m a -> Snap.Config m a
configureSSL cfgSsl config =
Snap.setSSLBind (jsSSLBind cfgSsl)
. Snap.setSSLPort (jsSSLPort cfgSsl)
. Snap.setSSLCert (jsSSLCert cfgSsl)
. Snap.setSSLKey (jsSSLKey cfgSsl)
$ Snap.setSSLChainCert (jsSSLChainCert cfgSsl) config
-- | Route the communication between JavaScript and the server
routeWebsockets :: (RequestInfo -> Comm -> IO void) -> Routes
routeWebsockets worker = [("websocket", response)]
where
response = do
requestInfo <- Snap.getRequest
WS.runWebSocketsSnap $ \ws -> void $ do
comm <- communicationFromWebSocket ws
worker (rqCookies requestInfo) comm
-- error "Foreign.JavaScript: unreachable code path."
-- | Create 'Comm' channel from WebSocket request.
communicationFromWebSocket :: WS.PendingConnection -> IO Comm
communicationFromWebSocket request = do
connection <- WS.acceptRequest request
commIn <- STM.newTQueueIO -- outgoing communication
commOut <- STM.newTQueueIO -- incoming communication
commOpen <- STM.newTVarIO True
-- write data to browser
let sendData = forever $ do
x <- atomically $ STM.readTQueue commOut
-- see note [ServerMsg strictness]
WS.sendTextData connection . JSON.encode $ x
-- read data from browser
let readData = forever $ do
input <- WS.receiveData connection
case input of
"ping" -> WS.sendTextData connection . LBS.pack $ "pong"
"quit" -> E.throwIO WS.ConnectionClosed
input -> case JSON.decode input of
Just x -> atomically $ STM.writeTQueue commIn x
Nothing -> error $
"Foreign.JavaScript: Couldn't parse JSON input"
++ show input
-- block until the channel is closed
let sentry = atomically $ do
open <- STM.readTVar commOpen
when open retry
-- explicitly close the Comm chanenl
let commClose = atomically $ STM.writeTVar commOpen False
-- read/write data until an exception occurs or the channel is no longer open
_ <- forkFinally (sendData `race_` readData `race_` sentry) $ \_ -> void $ do
-- close the communication channel explicitly if that didn't happen yet
commClose
-- attempt to close websocket if still necessary/possible
-- ignore any exceptions that may happen if it's already closed
let allExceptions :: E.SomeException -> Maybe ()
allExceptions _ = Just ()
E.tryJust allExceptions $ WS.sendClose connection $ LBS.pack "close"
return $ Comm {..}
{-----------------------------------------------------------------------------
Resources
------------------------------------------------------------------------------}
type Routes = [(ByteString, Snap ())]
routeResources :: Server -> Maybe FilePath -> Maybe FilePath -> Routes
routeResources server customHTML staticDir =
fixHandlers noCache $
static ++
[("/" , root)
,("/haskell.js" , writeTextMime jsDriverCode "application/javascript")
,("/haskell.css" , writeTextMime cssDriverCode "text/css")
,("/file/:name" ,
withFilepath (sFiles server) (flip serveFileAs))
,("/dir/:name" ,
withFilepath (sDirs server) (\path _ -> serveDirectory path))
]
where
fixHandlers f routes = [(a,f b) | (a,b) <- routes]
noCache h = modifyResponse (setHeader "Cache-Control" "no-cache") >> h
static = maybe [] (\dir -> [("/static", serveDirectory dir)]) staticDir
root = case customHTML of
Just file -> case staticDir of
Just dir -> serveFile (dir </> file)
Nothing -> logError "Foreign.JavaScript: Cannot use jsCustomHTML file without jsStatic"
Nothing -> writeTextMime defaultHtmlFile "text/html"
writeTextMime :: MonadSnap m => Text -> ByteString -> m ()
writeTextMime text mime = do
modifyResponse (setHeader "Content-type" mime)
writeText text
-- | Extract from a URI
withFilepath :: MVar Filepaths -> (FilePath -> ByteString -> Snap a) -> Snap a
withFilepath rDict cont = do
mName <- getParam "name"
(_,dict) <- liftIO $ withMVar rDict return
case (\key -> M.lookup key dict) =<< mName of
Just (path,mimetype) -> cont path (BS.pack mimetype)
Nothing -> error $ "File not loaded: " ++ show mName
-- FIXME: Serving large files fails with the exception
-- System.SendFile.Darwin: invalid argument (Socket is not connected)
-- | Associate an URL to a FilePath
newAssociation :: MVar Filepaths -> (FilePath, MimeType) -> IO String
newAssociation rDict (path,mimetype) = do
(old, dict) <- takeMVar rDict
let new = old + 1; key = show new ++ takeFileName path
putMVar rDict $ (new, M.insert (BS.pack key) (path,mimetype) dict)
return key
-- | Begin to serve a local file with a given 'MimeType' under a URI.
loadFile :: Server -> MimeType -> FilePath -> IO String
loadFile server mimetype path = do
key <- newAssociation (sFiles server) (path, mimetype)
return $ "/file/" ++ key
-- | Begin to serve a local directory under a URI.
loadDirectory :: Server -> FilePath -> IO String
loadDirectory server path = do
key <- newAssociation (sDirs server) (path,"")
return $ "/dir/" ++ key