{-# language BlockArguments, ScopedTypeVariables, OverloadedStrings, NamedFieldPuns, GeneralizedNewtypeDeriving, DataKinds, DeriveAnyClass, RankNTypes, LambdaCase, DerivingStrategies #-} {-| Description : Direct one-to-one typed bindings to the Myxine server API Usually, it makes the most sense to run a Myxine application using the 'Myxine.Page.Page' abstraction in the main module. However, this reactive model-view-controller approach may not be appropriate for all needs. The functions below are a one-to-one mapping to the API of the Myxine server. Like the Myxine server API itself, this interface has a small surface area. You can send a new page 'Update' using 'sendUpdate', you can loop over all page events using 'withEvents', and you can evaluate raw JavaScript using 'evaluateJs'. -} module Myxine.Direct ( -- * Page locations on @localhost@ PageLocation, pagePort, PagePort, pagePath, PagePath -- * Sending updates to pages , Update(..), PageContent, pageBody, pageTitle, sendUpdate -- * Looping over typed events , withEvents -- * Evaluating raw JavaScript in the context of a page , JavaScript(..), evaluateJs -- * Exceptions , EventParseException(..) , -- * The @Some@ existential ) where import Data.Maybe import Data.Monoid import Data.String import Control.Monad import Control.Concurrent import Data.List import Control.Exception import Data.Dependent.Map (Some(..)) import qualified Data.ByteString.Lazy.Char8 as ByteString import Data.ByteString.Lazy.Char8 (ByteString) import Data.Text (Text) import qualified Data.Text as Text import qualified Data.Text.Encoding as Text import qualified Data.Aeson as JSON import qualified Network.HTTP.Req as Req import Network.HTTP.Types (ok200) import Network.HTTP.Client (responseStatus, responseBody) import Myxine.Event import Myxine.Internal.EventStream import Myxine.Target -- | If the response from the server fails to parse, this exception is thrown. -- This should never happen in ordinary circumstances; if it does, your version -- of the client library may mismatch the version of the Myxine server you are -- running, or there may be a bug in the Myxine server or this library. -- -- If you encounter this exception in the wild, please file a bug report at -- . Thanks! data EventParseException = TargetParseException String -- ^ The target path failed to parse | UnknownEventTypeException ByteString -- ^ The event type failed to parse | EventDataParseException ByteString String ByteString -- ^ The properties associated with the event type failed to parse deriving (Eq, Ord, Exception) instance Show EventParseException where show exn = "*** Myxine client panic: Failed to parse " <> component <> "! This means one of:\n\n" <> " 1) You connected to an event source that is not the Myxine server\n" <> " 2) You connected to a Myxine server process with an incompatible major version\n" <> " 3) There is a bug in the Myxine server or client library (totally possible!)\n\n" <> " If you suspect it's (3), please file a bug report at:\n\n " <> bugReportURL <> "\n\n" <> " Please include the version of this library, the version of the Myxine server,\n" <> " and the following details:\n\n" <> details where component, details, bugReportURL :: String (component, details) = case exn of TargetParseException input -> ("target path", " - Unparseable target path: " <> show input) UnknownEventTypeException eventType -> ("event type", " - Unknown event type: " <> show eventType) EventDataParseException eventType parseError badInput -> ("event properties", " - Known event type: " <> show eventType <> "\n" <> " - Parse error: " <> parseError <> "\n" <> " - Bad input properties: " <> show badInput) bugReportURL = "https://github.com/GaloisInc/myxine/issues/new" -- | The view of a page, as rendered in the browser. Create page content with -- 'pageBody' and 'pageTitle', and combine content using the 'Semigroup' -- instance. -- -- __Note:__ The 'Semigroup' instance for 'PageContent' takes the last specified -- 'pageTitle' (if any), and concatenates in order each specified 'pageBody'. data PageContent = PageContent { pageContentBody :: Text , pageContentTitle :: Last Text } deriving (Eq, Ord, Show) instance Semigroup PageContent where PageContent body title <> PageContent body' title' = PageContent (body <> body') (title <> title') instance Monoid PageContent where mempty = PageContent mempty mempty -- | Create a rendered 'PageContent' with an empty @title@ and the specified -- text as its @body@. pageBody :: Text -> PageContent pageBody body = mempty { pageContentBody = body } -- | Create a rendered 'PageContent' with an empty @body@ and the specified -- text as its @title@. pageTitle :: Text -> PageContent pageTitle title = mempty { pageContentTitle = Last (Just title) } -- | A full page update as ready-to-send to the Myxine server. data Update = Dynamic -- ^ A dynamic page which can be updated live PageContent -- ^ The content of the page (create this using 'pageBody' and/or -- 'pageTitle', combining using the 'Semigroup' instance) | Static -- ^ A static file which is hosted precisely as specified ByteString -- ^ The @Content-Type@ of the content ByteString -- ^ The raw bytes to be served by the server deriving (Eq, Ord, Show) -- | Compute the localhost 'Req.Url' of a given path, allowing for dynamic -- appearances of @/@ in the URL. pageUrl :: PagePath -> Req.Url 'Req.Http pageUrl (PagePath p) = foldl' (Req./:) (Req.http "localhost") (Text.split ('/' ==) p) -- | A local port at which the server is expected to be running. Create one -- using an integer literal or 'fromInteger'. newtype PagePort = PagePort Int deriving newtype (Num, Eq, Ord, Show) -- | A path at "localhost/..." at which to perform some action. Create one using -- a string literal or 'Data.String.fromString'. newtype PagePath = PagePath Text deriving newtype (IsString, Eq, Ord, Show) -- | The options for connecting to the Myxine server. This is an opaque -- 'Monoid': set options by combining 'pagePort' and/or 'pagePath' using their -- 'Semigroup' instance. data PageLocation = PageLocation { pageLocationPort :: Last PagePort , pageLocationPath :: Last PagePath } deriving (Eq, Ord, Show) -- | Set the path to something other than the default of @/@. pagePath :: PagePath -> PageLocation pagePath p = mempty { pageLocationPath = Last (Just p) } -- | Set the port to a non-default port. This is only necessary when Myxine is -- running on a non-default port also. pagePort :: PagePort -> PageLocation pagePort p = mempty { pageLocationPort = Last (Just p) } -- | The default port for the Myxine server defaultPort :: Int defaultPort = 1123 instance Semigroup PageLocation where PageLocation port1 path1 <> PageLocation port2 path2 = PageLocation (port1 <> port2) (path1 <> path2) instance Monoid PageLocation where mempty = PageLocation { pageLocationPort = mempty , pageLocationPath = mempty } -- | Send a full-page update to Myxine at a particular port and path. An -- 'Update' is either a 'Dynamic' page body with an optional title, or a -- 'Static' file with a particular Content-Type. sendUpdate :: PageLocation {- ^ The location of the page to update -} -> Update {- ^ The new content of the page to display -} -> IO () sendUpdate PageLocation{pageLocationPort = Last maybePort, pageLocationPath = Last maybePath} update = do _ <- Req.runReq Req.defaultHttpConfig $ Req.req Req.POST url body Req.ignoreResponse (portOption <> params) pure () where url = pageUrl (fromMaybe "" maybePath) portOption = Req.port (maybe defaultPort (\(PagePort p) -> p) maybePort) body :: Req.ReqBodyLbs params :: Req.Option 'Req.Http (body, params) = case update of Dynamic (PageContent{pageContentTitle = Last maybeTitle, pageContentBody = text}) -> ( Req.ReqBodyLbs (ByteString.fromStrict (Text.encodeUtf8 text)) , foldMap ("title" Req.=:) maybeTitle ) Static contentType content -> ( Req.ReqBodyLbs content , Req.header "Content-Type" (ByteString.toStrict contentType) <> Req.queryFlag "static" ) -- | A piece of raw JavaScript to evaluate: either an expression or a block of -- statements. Expressions need not terminate with a @return@ statement but -- cannot span multiple lines; block need to have an explicit @return@, but can -- contain multiple statements and lines. data JavaScript = JsExpression Text -- ^ A JavaScript expression | JsBlock Text -- ^ A block of JavaScript statements deriving (Eq, Ord, Show) -- | Evaluate some raw JavaScript in the context of a given page -- -- Returns either a deserialized Haskell type, or a human-readable string -- describing any error that occurred. Possible errors include: -- -- * Any exception in the given JavaScript -- * Absence of any browser window currently viewing the page (since there's no -- way to evaluate JavaScript without a JavaScript engine) -- * Evaluation timeout (default is 1000 milliseconds, but can be overridden in -- the timeout parameter to this function -- * Invalid JSON response for the result type inferred (use 'JSON.Value' if you -- don't know what shape of data you're waiting to receive). -- -- Further caveats: -- -- * JavaScript @undefined@ is translated to @null@ in the results -- * 'JsBlock' inputs which don't explicitly return a value result in @null@ -- * Return types are limited to those which can be serialized via -- [@JSON.stringify@](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify), -- which does not work for cyclic objects (like @window@, @document@, and all -- DOM nodes), and may fail to serialize some properties for other non-scalar -- values. If you want to return a non-scalar value like a list or dictionary, -- construct it explicitly yourself by copying from the fields of the object -- you're interested in. evaluateJs :: JSON.FromJSON a => PageLocation {- ^ The location of the page in which to evaluate the JavaScript -} -> Maybe Int {- ^ An optional override for the default timeout of 1000 milliseconds -} -> JavaScript {- ^ The JavaScript to evaluate: either a 'JsExpression' or a 'JsBlock' -} -> IO (Either String a) evaluateJs PageLocation{pageLocationPort = Last maybePort, pageLocationPath = Last maybePath} timeout js = do result <- Req.runReq Req.defaultHttpConfig $ Req.req Req.POST url body Req.lbsResponse (portOption <> timeoutOption <> exprOption) pure if Req.responseStatusCode result == 200 then JSON.eitherDecode (Req.responseBody result) else Left (ByteString.unpack (Req.responseBody result)) where url = pageUrl (fromMaybe "" maybePath) portOption = Req.port (maybe defaultPort (\(PagePort p) -> p) maybePort) body :: Req.ReqBodyLbs exprOption, timeoutOption :: Req.Option 'Req.Http timeoutOption = foldMap ("timeout" Req.=:) timeout (body, exprOption) = case js of JsExpression expr -> (Req.ReqBodyLbs "", "evaluate" Req.=: expr) JsBlock block -> (Req.ReqBodyLbs (ByteString.fromStrict (Text.encodeUtf8 block)), Req.queryFlag "evaluate") -- | Given a page location and list of events, make a request to Myxine for the -- event stream at that location and run the provided callback for each typed -- event in the stream. If no list of events is specified (@Nothing@), every -- event will be listened for. If the specific list of no events (@Just []@) is -- specified, this function will sleep forever. -- -- This blocks until the stream is closed by the server, or an exception is -- encountered. This throws 'EventParseException' if the server sends an -- unparseable event (this shouldn't happen in normal operation), and -- 'Req.HttpException' if the underlying connection has an issue. withEvents :: PageLocation {- ^ The location of the page to listen for events from -} -> Maybe [Some EventType] {- ^ The list of events for which to listen -} -> (forall d. EventType d -> d -> [Target] -> IO ()) {- ^ The action to perform when each event happens -} -> IO () withEvents _ (Just []) _ = -- if the user requests no events, there is no corresponding Myxine API -- request to handle this, so we just sleep forever forever (threadDelay maxBound) withEvents PageLocation{pageLocationPort = Last maybePort, pageLocationPath = Last maybePath} events perEvent = do withStreamEvents url (portOption <> eventParams) \StreamEvent{eventId, eventType, eventData} -> do targets <- either (throwIO . TargetParseException) pure (JSON.eitherDecode eventId) withParsedEvent eventType eventData \case Left Nothing -> throwIO (UnknownEventTypeException eventType) Left (Just err) -> throwIO (EventDataParseException eventType err eventData) Right (eventTy, properties) -> perEvent eventTy properties targets where url = pageUrl (fromMaybe "" maybePath) portOption = Req.port (maybe defaultPort (\(PagePort p) -> p) maybePort) eventParams :: Req.Option 'Req.Http eventParams = case events of Nothing -> Req.queryFlag "events" Just es -> flip foldMap es \(Some e) -> "event" Req.=: ByteString.unpack (encodeEventType e) -- | Given an event name and properties as raw bytestrings, invoke the given -- callback if the event parses properly, or return 'Nothing'. withParsedEvent :: ByteString -> ByteString -> (forall d. Either (Maybe String) (EventType d, d) -> r) -> r withParsedEvent name properties k = case decodeSomeEventType name of Nothing -> k (Left Nothing) Just (Some t) -> case decodeEventProperties t properties of Left err -> k (Left (Just err)) Right p -> k (Right (t, p)) -- | Request an event-stream from the given 'Req.Url' with the given -- 'Req.Option's, and run the provided callback for each StreamEvent. This -- throws 'Req.HttpException' when the underlying connection has an issue. withStreamEvents :: Req.Url scheme -> Req.Option scheme -> (StreamEvent -> IO ()) -> IO () withStreamEvents url options withEvent = Req.runReq Req.defaultHttpConfig $ Req.reqBr Req.GET url Req.NoReqBody options \response -> if responseStatus response /= ok200 then pure () else do let nextChunk = ByteString.fromStrict <$> responseBody response nextLine <- linesFromChunks nextChunk nextEvent <- eventsFromLines nextLine let loop = do maybeEvent <- nextEvent maybe (pure ()) (\e -> withEvent e >> loop) maybeEvent loop