sunroof-server-0.2: Language/Sunroof/Server.hs
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
-- | The Sunroof server module provides infrastructure to use
-- Sunroof together with kansas-comet.
--
-- It supports setting up a simple server with 'sunroofServer'
-- and provides basic functions for serverside communication
-- with the connected website ('syncJS', 'asyncJS' and 'rsyncJS').
--
-- This module also provides the abstractions for 'Downlink'
-- and 'Uplink'. They represent directed channels for sending data
-- from the server to the website and the other way aroun.
-- The sent data is queued and operations block properly if there
-- is no data available.
module Language.Sunroof.Server
(
-- * Basic Comet Server
syncJS
, asyncJS
, rsyncJS
, SunroofResult(..)
, SunroofEngine(..)
, jsonToJS
, sunroofServer
, SunroofServerOptions(..)
, SunroofApp
, debugSunroofEngine
-- * Downlink
, Downlink
, newDownlink
, getDownlink
, putDownlink
-- * Uplink
, Uplink
, newUplink
, getUplink
, putUplink
-- * Timing
, Timings(..)
, newTimings
, resetTimings
, getTimings
) where
import Data.Aeson.Types ( Value(..), Object, Array )
import Data.Attoparsec.Number ( Number(..) )
import Data.List ( intercalate )
import Data.Text ( unpack )
import Data.Proxy ( Proxy(..) )
import Data.Default ( Default(..) )
import Data.Semigroup
import Data.Time.Clock
import qualified Data.Vector as V
import qualified Data.HashMap.Strict as M
import System.FilePath((</>))
import Control.Monad.IO.Class ( liftIO )
import Control.Concurrent.STM
import Network.Wai.Handler.Warp ( Port, settingsPort )
import Network.Wai.Middleware.Static
import qualified Web.Scotty as SC
import Web.KansasComet
( send, connect
, Document, Options
, kCometPlugin )
import qualified Web.KansasComet as KC
import Language.Sunroof
import Language.Sunroof.JavaScript
( Expr
, literal, showExpr
, scopeForEffect )
import Language.Sunroof.Classes ( Uniq )
import Language.Sunroof.Compiler ( compileJS )
-- -------------------------------------------------------------
-- Communication and Compilation
-- -------------------------------------------------------------
-- | The 'SunroofEngine' provides the verbosity level and
-- kansas comet document to the 'SunroofApp'.
data SunroofEngine = SunroofEngine
{ cometDocument :: Document
-- ^ The document comet uses to manage the connected website.
, uVar :: TVar Uniq
-- ^ Unique number supply for our engine
, engineVerbose :: Int
-- ^ @0@ for none, @1@ for initializations,
-- @2@ for commands done and @3@ for a complete log.
, compilerOpts :: CompilerOpts
-- ^ The options used to setup the compiler.
, timings :: Maybe (TVar (Timings NominalDiffTime))
-- ^ Performance timings of the compiler and communication.
}
-- | Generate one unique integer from the document.
docUniq :: SunroofEngine -> IO Int
docUniq = docUniqs 1
-- | Generate n unique integers from the document.
docUniqs :: Int -> SunroofEngine -> IO Int
docUniqs n doc = atomically $ do
u <- readTVar (uVar doc)
writeTVar (uVar doc) (u + n)
return u
-- | The number of uniques allocated for the first try of a compilation.
compileUniqAlloc :: Uniq
compileUniqAlloc = 32
-- | Log the given message on the given level
sunroofLog :: SunroofEngine -> Int -> String -> IO ()
sunroofLog engine level msg =
if (engineVerbose engine >= level)
then do
putStr "Sunroof> "
putStrLn msg
else return ()
-- | Log the compilation result and return it
compileLog :: SunroofEngine -> String -> IO ()
compileLog engine src = do
sequence_ $ fmap (sunroofLog engine 3) $
[ "Compiled:", src]
return ()
-- | Compile js using unique variables each time.
compileRequestJS :: SunroofEngine -> JS t () -> IO String
compileRequestJS engine jsm = do
-- Allocate a standard amount of uniq for compilation
uq <- docUniqs compileUniqAlloc engine
-- Compile
(stmts, uq') <- compileJS (compilerOpts engine) uq return jsm
-- Check if the allocated amount was sufficient
let txt = showExpr False $ scopeForEffect stmts
if (uq' < uq + compileUniqAlloc)
-- It was sufficient we are finished
then do compileLog engine txt
return txt
-- It wasn't sufficient
else do
-- Allocate all that are needed
newUq <- docUniqs (uq' - uq) engine
-- Compile again
(stmts', _) <- compileJS (compilerOpts engine) newUq return jsm
let txt' = showExpr False $ scopeForEffect stmts'
compileLog engine txt'
return txt'
-- | Executes the Javascript in the browser without waiting for a result.
asyncJS :: SunroofEngine -> JS t () -> IO ()
asyncJS engine jsm = do
t0 <- getCurrentTime
src <- compileRequestJS engine jsm
addCompileTime engine t0
t1 <- getCurrentTime
send (cometDocument engine) src -- send it, and forget it
addSendTime engine t1
return ()
-- | Executes the Javascript in the browser and waits for the result value.
-- The result value is given the corresponding Haskell type,
-- if possible (see 'SunroofResult').
syncJS :: forall a t . (SunroofResult a) => SunroofEngine -> JS t a -> IO (ResultOf a)
syncJS engine jsm | typeOf (Proxy :: Proxy a) == Unit = do
_ <- syncJS engine (jsm >> return (0 :: JSNumber))
return $ jsonToValue (Proxy :: Proxy a) Null
syncJS engine jsm = do
up <- newUplink engine
t0 <- getCurrentTime
src <- compileRequestJS engine $ do
v <- jsm
up # putUplink v
addCompileTime engine t0
t1 <- getCurrentTime
send (cometDocument engine) src
addSendTime engine t1
t2 <- getCurrentTime
-- There is *no* race condition in here. If no-one is listening,
-- then the numbered event gets queued up.
r <- getUplink up
addWaitTime engine t2
return r
-- | Executes the Javascript in the browser and waits for the result.
-- The returned value is just a reference to the computed value.
-- This allows to precompile values like function in the browser.
rsyncJS :: forall a t . (Sunroof a) => SunroofEngine -> JS t a -> IO a
rsyncJS engine jsm = do
uq <- docUniq engine -- uniq for the value
let uq_lab = label ("remote_" <> cast (js uq))
up :: Uplink JSNumber <- newUplink engine
t0 <- getCurrentTime
src <- compileRequestJS engine $ do
v <- jsm
-- Store the value inside the window object
object "window" # uq_lab := v
up # putUplink 0
addCompileTime engine t0
t1 <- getCurrentTime
send (cometDocument engine) src
addSendTime engine t1
t2 <- getCurrentTime
-- There is *no* race condition in here. If no-one is listening,
-- then the numbered event gets queued up.
_ <- getUplink up
addWaitTime engine t2
return $ object "window" ! uq_lab
-- -----------------------------------------------------------------------
-- Default Server Instance
-- -----------------------------------------------------------------------
-- | A comet application takes the engine/document we are currently communicating
-- with and delivers the IO action to be executed as server application.
type SunroofApp = SunroofEngine -> IO ()
-- | The 'SunroofServerOptions' specify the configuration of the
-- sunroof comet server infrastructure.
--
-- See 'sunroofServer' and 'SunroofServerOptions' for further information.
data SunroofServerOptions = SunroofServerOptions
{ cometPort :: Port
-- ^ The port the server is reachable from.
, cometResourceBaseDir :: FilePath
-- ^ Will be used as base directory to search for all static files.
-- Make this path absolute to run the server from anywhere.
, cometIndexFile :: FilePath
-- ^ The file to be used as index file (or landing page).
-- This path is given relative to the 'cometResourceBaseDir'.
, cometPolicy :: Policy
-- ^ The default policy is to allow the @css@, @img@ and @js@
-- folders to be used by the server, as well as the noDots policy.
-- This policy can be overwritten to allow delivery of other files.
, cometOptions :: Options
-- ^ Provides the kansas comet options to use.
-- Default options are provided with the 'Data.Default.def' instance.
, sunroofVerbose :: Int
-- ^ @0@ for none, @1@ for initializations,
-- @2@ for commands done and @3@ for a complete log.
, sunroofCompilerOpts :: CompilerOpts
-- ^ The set of options to configure the Sunroof compiler.
-- Default options are provided with the 'Data.Default.def' instance.
}
-- | Sets up a comet server ready to use with sunroof.
--
-- @sunroofServer opts app@:
-- The @opts@ give various configuration for the comet server.
-- See 'SunroofServerOptions' for further information on this.
-- The application to run is given by @app@. It takes the current
-- engine/document as parameter. The document is needed for calls to 'syncJS',
-- 'asyncJS' and 'rsyncJS'.
--
-- The server provides the kansas comet Javascript on the path
-- @js/kansas-comet.js@.
--
-- Since @kansas-comet.js@ is a JQuery plugin you have to also
-- load a decent version of @jquery.js@ (or @jquery.min.js@)
-- and also @jquery-json.js@. They are available at:
--
-- * <http://jquery.com/>
--
-- * <https://code.google.com/p/jquery-json/>
--
-- For the index file to setup the communication correctly with the comet
-- server it has to load the @kansas-comet.js@ after the JQuery code
-- inside the @head@ (assuming you placed the JQuery code under @js/@):
--
-- > <script type="text/javascript" src="js/jquery.js"></script>
-- > <script type="text/javascript" src="js/jquery-json.js"></script>
-- > <script type="text/javascript" src="js/kansas-comet.js"></script>
--
-- It also has to execute the following Javascript at the end of the
-- index file to initialize the communication:
--
-- > <script type="text/javascript">
-- > $(document).ready(function() {
-- > $.kc.connect("/ajax");
-- > });
-- > </script>
--
-- The string @/ajax@ has to be set to whatever the comet prefix
-- in the 'Options' provided by the 'SunroofServerOptions' is.
-- These snippits will work for the 'def' instance.
--
-- Additional debug information can be displayed in the browser when
-- adding the following element to the index file:
--
-- > <div id="debug-log"></div>
--
-- Look into the example folder to see all of this in action.
sunroofServer :: SunroofServerOptions -> SunroofApp -> IO ()
sunroofServer opts cometApp = do
let warpSettings = (SC.settings def) { settingsPort = cometPort opts }
-- Be quiet scotty! ... and beam me up!
let scottyOptions = def { SC.verbose = 0
, SC.settings = warpSettings }
SC.scottyOpts scottyOptions $ do
kcomet <- liftIO kCometPlugin
let rootFile = cometResourceBaseDir opts </> cometIndexFile opts
let custom_policy = cometPolicy opts
let pol = only [("", rootFile)
,("js/kansas-comet.js", kcomet)]
<|> (custom_policy
>-> addBase (cometResourceBaseDir opts))
SC.middleware $ staticPolicy pol
connect (cometOptions opts) $ wrapDocument opts cometApp
-- | Wrap the document into the sunroof engine.
wrapDocument :: SunroofServerOptions -> SunroofApp -> (Document -> IO ())
wrapDocument opts cometApp doc = do
uqVar <- atomically $ newTVar 0
cometApp $ SunroofEngine
{ cometDocument = doc
, uVar = uqVar
, engineVerbose = sunroofVerbose opts
, compilerOpts = sunroofCompilerOpts opts
, timings = Nothing
}
-- | Default options to use for the sunroof comet server.
--
-- [@cometPort@] Default port is @3000@.
--
-- [@cometResourceBaseDir@] Default resource location is @"."@.
--
-- [@cometIndexFile@] Default index file is @"index.html"@.
--
-- [@cometOptions@] Uses the server path @/ajax@ for the
-- comet JSON communication. Sets verbosity to @0@ (quiet).
--
-- [@sunroofVerbose@] Is set to @0@ (quiet).
--
defaultServerOpts :: SunroofServerOptions
defaultServerOpts = SunroofServerOptions
{ cometPort = 3000
, cometResourceBaseDir = "."
, cometIndexFile = "index.html"
, cometPolicy = defaultPolicy
, cometOptions = def { KC.prefix = "/ajax", KC.verbose = 0 }
, sunroofVerbose = 0
, sunroofCompilerOpts = def
}
defaultPolicy :: Policy
defaultPolicy = noDots >-> (hasPrefix "css/" <|>
hasPrefix "js/" <|>
hasPrefix "img/")
-- | The 'defaultServerOpts'.
instance Default SunroofServerOptions where
def = defaultServerOpts
-- -------------------------------------------------------------
-- Downlink API
-- -------------------------------------------------------------
-- | 'Downlink's are an abstraction provided for sending
-- Javascript data from the server to the website.
-- The type parameter describes the elements
-- that are transmited through the downlink.
data Downlink a = Downlink SunroofEngine (JSChan a)
-- | Create a new downlink.
newDownlink :: forall a . (Sunroof a, SunroofArgument a)
=> SunroofEngine -> IO (Downlink a)
newDownlink eng = do
chan <- rsyncJS eng (newChan :: JSA (JSChan a))
return $ Downlink eng chan
-- | Send data to the website.
putDownlink :: (Sunroof a, SunroofArgument a)
=> Downlink a -> JSA a -> IO ()
putDownlink (Downlink eng chan) val = asyncJS eng $ do
v <- val
writeChan v chan
-- | Request data in the downlink. This may block until
-- data is available.
getDownlink :: (Sunroof a, SunroofArgument a)
=> Downlink a -> JSB a
getDownlink (Downlink _eng chan) = readChan chan
-- -------------------------------------------------------------
-- Uplink API
-- -------------------------------------------------------------
-- | 'Uplink's are an abstraction provided for sending
-- Javascript data from the website back to the server.
-- Only data that can be translated back to a Haskell
-- value can be sent back.
-- The type parameter describes the elements
-- that are transmited through the uplink.
data Uplink a = Uplink SunroofEngine Uniq
-- | Create a new uplink.
newUplink :: SunroofEngine -> IO (Uplink a)
newUplink eng = do
u <- docUniq eng
return $ Uplink eng u
-- | Send Javascript data back to the server.
putUplink :: (Sunroof a) => a -> Uplink a -> JS t ()
putUplink a (Uplink _ u) = kc_reply (js u) a
-- | Request data in the uplink. This may block until
-- data is available.
getUplink :: forall a . (SunroofResult a) => Uplink a -> IO (ResultOf a)
getUplink (Uplink eng u) = do
val <- KC.getReply (cometDocument eng) u
-- TODO: make this throw an exception if it goes wrong (I supose error does this already)
return $ jsonToValue (Proxy :: Proxy a) val
-- -------------------------------------------------------------
-- Comet Javascript API
-- -------------------------------------------------------------
-- | Binding for the Javascript function to send replies to the
-- server.
kc_reply :: (Sunroof a) => JSNumber -> a -> JS t ()
kc_reply n a = fun "$.kc.reply" `apply` (n,a)
-- -----------------------------------------------------------------------
-- JSON Value to Haskell/Sunroof conversion
-- -----------------------------------------------------------------------
-- | Provides correspondant Haskell types for certain Sunroof types.
class (Sunroof a) => SunroofResult a where
-- | The Haskell value type associated with this 'Sunroof' type.
type ResultOf a
-- | Converts the given JSON value to the corresponding
-- Haskell value. A error is thrown if the JSON value can
-- not be converted.
jsonToValue :: Proxy a -> Value -> ResultOf a
-- | @null@ can be translated to @()@.
instance SunroofResult () where
type ResultOf () = ()
jsonToValue _ (Null) = ()
jsonToValue _ v = error $ "jsonToValue: JSON value is not unit: " ++ show v
-- | 'JSBool' can be translated to 'Bool'.
instance SunroofResult JSBool where
type ResultOf JSBool = Bool
jsonToValue _ (Bool b) = b
jsonToValue _ v = error $ "jsonToValue: JSON value is not a boolean: " ++ show v
-- | 'JSNumber' can be translated to 'Double'.
instance SunroofResult JSNumber where
type ResultOf JSNumber = Double
jsonToValue _ (Number (I i)) = fromInteger i
jsonToValue _ (Number (D d)) = d
jsonToValue _ v = error $ "jsonToValue: JSON value is not a number: " ++ show v
-- | 'JSString' can be translated to 'String'.
instance SunroofResult JSString where
type ResultOf JSString = String
jsonToValue _ (String s) = unpack s
jsonToValue _ v = error $ "jsonToValue: JSON value is not a string: " ++ show v
-- | 'JSArray' can be translated to a list of the 'ResultOf' the values.
instance forall a . SunroofResult a => SunroofResult (JSArray a) where
type ResultOf (JSArray a) = [ResultOf a]
jsonToValue _ (Array ss) = map (jsonToValue (Proxy :: Proxy a)) $ V.toList ss
jsonToValue _ v = error $ "jsonToValue: JSON value is not an array : " ++ show v
-- | Converts a JSON value to a Sunroof Javascript expression.
jsonToJS :: Value -> Expr
jsonToJS (Bool b) = unbox $ js b
jsonToJS (Number (I i)) = unbox $ js i
jsonToJS (Number (D d)) = unbox $ js d
jsonToJS (String s) = unbox $ js $ unpack s
jsonToJS (Null) = unbox $ nullJS
jsonToJS (Array arr) = jsonArrayToJS arr
jsonToJS (Object obj) = jsonObjectToJS obj
-- | Converts a JSON object to a Sunroof expression.
jsonObjectToJS :: Object -> Expr
jsonObjectToJS obj = literal $
let literalMap = M.toList $ fmap (show . jsonToJS) obj
convertKey k = "\"" ++ unpack k ++ "\""
keyValues = fmap (\(k,v) -> convertKey k ++ ":" ++ v) literalMap
in "{" ++ intercalate "," keyValues ++ "}"
-- | Converts a JSON array to a Sunroof expression.
jsonArrayToJS :: Array -> Expr
jsonArrayToJS arr = literal $
"(new Array(" ++ (intercalate "," $ V.toList $ fmap (show . jsonToJS) arr) ++ "))"
{- Orphans:
instance SunroofValue Value where
type ValueOf Value = JSObject
js = box . jsonToJS
instance SunroofValue Text where
type ValueOf Text = JSString
js = string . unpack
-}
-- -------------------------------------------------------------
-- Debugging
-- -------------------------------------------------------------
-- | Setup a 'SunroofEngine' for debugging.
debugSunroofEngine :: IO SunroofEngine
debugSunroofEngine = do
doc <- KC.debugDocument
uqVar <- atomically $ newTVar 0
return $ SunroofEngine doc uqVar 3 def Nothing
-- | Timings for communication and compilation.
data Timings a = Timings
{ compileTime :: !a -- ^ How long spent compiling.
, sendTime :: !a -- ^ How long spent sending.
, waitTime :: !a -- ^ How long spent waiting for a response.
}
deriving Show
-- | Apply a function by applying it to each timing.
instance Functor Timings where
fmap f (Timings t1 t2 t3) = Timings (f t1) (f t2) (f t3)
-- | Combine timings by combining each single timing.
instance Semigroup a => Semigroup (Timings a) where
(Timings t1 t2 t3) <> (Timings u1 u2 u3) = Timings (t1<>u1) (t2<>u2) (t3<>u3)
-- | Create timings in the 'SunroofEngine'.
newTimings :: SunroofEngine -> IO SunroofEngine
newTimings e = do
v <- atomically $ newTVar $ Timings 0 0 0
return $ e { timings = Just v }
-- | Reset all timings.
resetTimings :: SunroofEngine -> IO ()
resetTimings (SunroofEngine { timings = Nothing }) = return ()
resetTimings (SunroofEngine { timings = Just t }) = atomically $ writeTVar t $ Timings 0 0 0
-- | Get timings from the 'SunroofEngine'.
getTimings :: SunroofEngine -> IO (Timings NominalDiffTime)
getTimings (SunroofEngine { timings = Nothing }) = return $ Timings 0 0 0
getTimings (SunroofEngine { timings = Just t }) = atomically $ readTVar t
-- | Add a timing for compilation.
addCompileTime :: SunroofEngine -> UTCTime -> IO ()
addCompileTime (SunroofEngine { timings = Nothing }) _start = return ()
addCompileTime (SunroofEngine { timings = Just t }) start = do
end <- getCurrentTime
atomically $ modifyTVar t $ \ ts -> ts { compileTime = compileTime ts + diffUTCTime end start}
return ()
-- | Add a timing for sending.
addSendTime :: SunroofEngine -> UTCTime -> IO ()
addSendTime (SunroofEngine { timings = Nothing }) _start = return ()
addSendTime (SunroofEngine { timings = Just t }) start = do
end <- getCurrentTime
atomically $ modifyTVar t $ \ ts -> ts { sendTime = sendTime ts + diffUTCTime end start}
return ()
-- | Add a timing for waiting for a response.
addWaitTime :: SunroofEngine -> UTCTime -> IO ()
addWaitTime (SunroofEngine { timings = Nothing }) _start = return ()
addWaitTime (SunroofEngine { timings = Just t }) start = do
end <- getCurrentTime
atomically $ modifyTVar t $ \ ts -> ts { waitTime = waitTime ts + diffUTCTime end start}
return ()