1
1
mirror of https://github.com/srid/ema.git synced 2024-11-25 20:12:20 +03:00

Add logging support (#17)

* Prototype logging using monad-logger-extras

* Use logging in Server.hs

* Add logging to generate

* Cleanup websocket logging

* Cleanups
This commit is contained in:
Sridhar Ratnakumar 2021-04-26 12:46:35 -04:00 committed by GitHub
parent b0354babcd
commit bb9c95a4b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 207 additions and 127 deletions

View File

@ -2,7 +2,7 @@
<img width="10%" src="./docs/ema.svg">
Ema is a next-gen **Haskell** library for building [jamstack-style](https://jamstack.org/) static sites, with fast hot reload. See [ema.srid.ca](https://ema.srid.ca/) for documentation.
Ema is a next-gen **Haskell** library for building [jamstack-style](https://jamstack.org/) static sites, with fast hot reload. See [ema.srid.ca](https://ema.srid.ca/) for further information.
The simplest Ema app looks like this:
@ -10,7 +10,7 @@ The simplest Ema app looks like this:
main :: IO ()
main = do
let name :: Text = "Ema"
runEmaPure $
runEmaPure $ \_ ->
encodeUtf8 $ "<b>Hello</b>, from " <> name
```

View File

@ -4,4 +4,5 @@
* [LVar](concepts/lvar.md)
* [Slug](concepts/slug.md)
* [CLI](concepts/cli.md)
* [Logging](concepts/logging.md)
* [Haskell's Safety](concepts/haskell-safety.md)

6
docs/concepts/logging.md Normal file
View File

@ -0,0 +1,6 @@
# Logging
TODO
- monad-logger
- CLI log levels

View File

@ -3,6 +3,7 @@
TODO
- What's a "model"?
- Represents all the 'state' in your app.
- Any Haskell type
- Stored in `LVar` to enable hot-reload
- `LVar.set` and `LVar.modify`

View File

@ -38,18 +38,21 @@ library
build-depends:
, aeson
, async
, base ^>=4.14.1.0
, base ^>=4.14.1.0
, containers
, data-default
, directory
, filepath
, http-types
, monad-logger
, monad-logger-extras
, neat-interpolation
, optparse-applicative
, relude
, safe-exceptions
, stm
, text
, unliftio
, wai
, wai-middleware-static
, wai-websockets

View File

@ -21,6 +21,7 @@
inherit name returnShellEnv;
root = ./.;
withHoogle = false;
overrides = self: super: with pkgs.haskell.lib; { };
modifier = drv:
pkgs.haskell.lib.addBuildTools drv (with pkgs.haskellPackages;
[

View File

@ -6,4 +6,5 @@ module Ema
where
import Ema.App as X
import Ema.Class as X
import Ema.Route as X

View File

@ -1,4 +1,6 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
@ -6,20 +8,21 @@ module Ema.App
( runEma,
runEmaPure,
runEmaWithCli,
Ema (..),
MonadEma,
)
where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (race_)
import Control.Monad.Logger
import Control.Monad.Logger.Extras
import Data.LVar (LVar)
import qualified Data.LVar as LVar
import Ema.CLI (Action (..), Cli)
import qualified Ema.CLI as CLI
import Ema.Class (Ema (..))
import Ema.Class (Ema (..), MonadEma)
import qualified Ema.Generate as Generate
import qualified Ema.Server as Server
import GHC.IO.Handle (BufferMode (LineBuffering), hSetBuffering)
import System.Directory (getCurrentDirectory, withCurrentDirectory)
import System.Environment (lookupEnv)
@ -31,13 +34,12 @@ import System.Environment (lookupEnv)
-- function.
runEmaPure ::
-- | How to render a route
(Cli -> LByteString) ->
(CLI.Action -> LByteString) ->
IO ()
runEmaPure html = do
cli <- CLI.cliAction
runEmaWithCli cli (\_ () () -> html cli) $ \model -> do
runEmaPure render = do
runEma (\act () () -> render act) $ \model -> do
LVar.set model ()
threadDelay maxBound
liftIO $ threadDelay maxBound
-- | Convenient version of @runEmaWith@ that takes initial model and an update
-- function. You typically want to use this.
@ -51,7 +53,7 @@ runEma ::
(CLI.Action -> model -> route -> LByteString) ->
-- | A long-running IO action that will update the @model@ @LVar@ over time.
-- This IO action must set the initial model value in the very beginning.
(LVar model -> IO ()) ->
(forall m. MonadEma m => LVar model -> m ()) ->
IO ()
runEma render runModel = do
cli <- CLI.cliAction
@ -68,26 +70,26 @@ runEmaWithCli ::
(CLI.Action -> model -> route -> LByteString) ->
-- | A long-running IO action that will update the @model@ @LVar@ over time.
-- This IO action must set the initial model value in the very beginning.
(LVar model -> IO ()) ->
(forall m. MonadEma m => LVar model -> m ()) ->
IO ()
runEmaWithCli cli render runModel = do
model <- LVar.empty
-- TODO: Use a logging library, in place of managing buffering and using putStrLn
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
-- TODO: Allow library users to control logging levels
let logger = colorize logToStdout
withCurrentDirectory (CLI.workingDir cli) $ do
cwd <- getCurrentDirectory
putStrLn $ "Running Ema under: " <> cwd
putStrLn "Waiting for initial site model ..."
putStrLn " stuck here? set a model value using `LVar.set`"
flip runLoggerLoggingT logger $ do
logInfoN $ "Running Ema under: " <> toText cwd
logInfoN "Waiting for initial site model ..."
logInfoN " stuck here? set a model value using `LVar.set`"
race_
(runModel model)
(runEmaWithCliInCwd (CLI.action cli) model render)
(flip runLoggerLoggingT logger $ runModel model)
(flip runLoggerLoggingT logger $ runEmaWithCliInCwd (CLI.action cli) model render)
-- | Run Ema live dev server
runEmaWithCliInCwd ::
forall model route.
(Ema model route, Show route) =>
forall model route m.
(MonadEma m, Ema model route, Show route) =>
-- | CLI arguments
CLI.Action ->
-- | Your site model type, as a @LVar@ in order to support modifications over
@ -101,7 +103,7 @@ runEmaWithCliInCwd ::
-- @route@ type as arguments. It must return the raw HTML to render to browser
-- or generate on disk.
(Action -> model -> route -> LByteString) ->
IO ()
m ()
runEmaWithCliInCwd cliAction model render = do
case cliAction of
Generate dest -> do
@ -109,6 +111,5 @@ runEmaWithCliInCwd cliAction model render = do
Generate.generate dest val (render cliAction)
Run -> do
void $ LVar.get model
port <- fromMaybe 8000 . (readMaybe @Int =<<) <$> lookupEnv "PORT"
putStrLn $ "Launching Ema at http://localhost:" <> show port
port <- liftIO $ fromMaybe 8000 . (readMaybe @Int =<<) <$> lookupEnv "PORT"
Server.runServerWithWebSocketHotReload port model (render cliAction)

View File

@ -1,10 +1,19 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
module Ema.Class where
import Control.Monad.Logger (MonadLoggerIO)
import Ema.Route.Slug (Slug)
import UnliftIO (MonadUnliftIO)
type MonadEma m =
( MonadIO m,
MonadUnliftIO m,
MonadLoggerIO m
)
-- | Enrich a model to work with Ema
class Ema model route | route -> model where

View File

@ -40,8 +40,8 @@ main :: IO ()
main = do
runEma render $ \model ->
forever $ do
LVar.set model =<< getCurrentTime
threadDelay $ 1 * 1000000
LVar.set model =<< liftIO getCurrentTime
liftIO $ threadDelay $ 1 * 1000000
render :: Ema.CLI.Action -> UTCTime -> Route -> LByteString
render emaAction now r =

View File

@ -16,6 +16,7 @@ import qualified Commonmark as CM
import qualified Commonmark.Extensions as CE
import qualified Commonmark.Pandoc as CP
import Control.Exception (throw)
import Control.Monad.Logger
import qualified Data.LVar as LVar
import qualified Data.List.NonEmpty as NE
import qualified Data.Map.Strict as Map
@ -91,28 +92,31 @@ instance Ema MarkdownSources MarkdownPath where
staticAssets _ =
["manifest.json", "ema.svg"]
log :: MonadLogger m => Text -> m ()
log = logInfoNS "Ex03_Documentation"
main :: IO ()
main =
runEma render $ \model -> do
LVar.set model =<< do
putStrLn "Loading .md files"
mdFiles <- FileSystem.filesMatching "." ["**/*.md"]
forM mdFiles readSource
<&> Tagged . Map.fromList . catMaybes
FileSystem.onChange "." $ \fp -> \case
FileSystem.Update ->
whenJustM (readSource fp) $ \(spath, s) -> do
putStrLn $ "Update: " <> show spath
log $ "Update: " <> show spath
LVar.modify model $ Tagged . Map.insert spath s . untag
FileSystem.Delete ->
whenJust (mkMarkdownPath fp) $ \spath -> do
putStrLn $ "Delete: " <> show spath
log $ "Delete: " <> show spath
LVar.modify model $ Tagged . Map.delete spath . untag
where
readSource :: FilePath -> IO (Maybe (MarkdownPath, Pandoc))
readSource :: (MonadIO m, MonadLogger m) => FilePath -> m (Maybe (MarkdownPath, Pandoc))
readSource fp =
runMaybeT $ do
spath :: MarkdownPath <- MaybeT $ pure $ mkMarkdownPath fp
log $ "Reading " <> toText fp
s <- readFileText fp
pure (spath, parseMarkdown s)

View File

@ -5,30 +5,35 @@
module Ema.Generate where
import Control.Exception (throw)
import Control.Monad.Logger
import Ema.Class
import Ema.Route (routeFile)
import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
import System.FilePattern.Directory (getDirectoryFiles)
log :: MonadLogger m => LogLevel -> Text -> m ()
log = logWithoutLoc "Generate"
generate ::
forall model route.
Ema model route =>
forall model route m.
(MonadEma m, Ema model route) =>
FilePath ->
model ->
(model -> route -> LByteString) ->
IO ()
m ()
generate dest model render = do
unlessM (doesDirectoryExist dest) $ do
unlessM (liftIO $ doesDirectoryExist dest) $ do
error "Destination does not exist"
let routes = staticRoutes model
putStrLn $ "Writing " <> show (length routes) <> " routes"
log LevelInfo $ "Writing " <> show (length routes) <> " routes"
forM_ routes $ \r -> do
let fp = dest </> routeFile @model r
putStrLn $ "W " <> fp
log LevelInfo $ toText $ "W " <> fp
let !s = render model r
createDirectoryIfMissing True (takeDirectory fp)
writeFileLBS fp s
liftIO $ do
createDirectoryIfMissing True (takeDirectory fp)
writeFileLBS fp s
forM_ (staticAssets $ Proxy @route) $ \staticPath -> do
copyDirRecursively staticPath dest
@ -36,26 +41,28 @@ newtype StaticAssetMissing = StaticAssetMissing FilePath
deriving (Show, Exception)
copyDirRecursively ::
MonadEma m =>
-- | Source file or directory relative to CWD that will be copied
FilePath ->
-- | Directory *under* which the source file/dir will be copied
FilePath ->
IO ()
m ()
copyDirRecursively srcRel destParent =
doesFileExist srcRel >>= \case
liftIO (doesFileExist srcRel) >>= \case
True -> do
let b = destParent </> srcRel
putStrLn $ "C " <> b
copyFile srcRel b
log LevelInfo $ toText $ "C " <> b
liftIO $ copyFile srcRel b
False ->
doesDirectoryExist srcRel >>= \case
liftIO (doesDirectoryExist srcRel) >>= \case
False ->
throw $ StaticAssetMissing srcRel
True -> do
fs <- getDirectoryFiles srcRel ["**"]
fs <- liftIO $ getDirectoryFiles srcRel ["**"]
forM_ fs $ \fp -> do
let a = srcRel </> fp
b = destParent </> srcRel </> fp
putStrLn $ "C " <> b
createDirectoryIfMissing True (takeDirectory b)
copyFile a b
log LevelInfo $ toText $ "C " <> b
liftIO $ do
createDirectoryIfMissing True (takeDirectory b)
copyFile a b

View File

@ -1,46 +1,75 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeFamilies #-}
-- | Helper to read a directory of files, and observe it for changes.
--
-- Use @new@ in conjunction with @observe@ in your @runEma@ function call.
module Ema.Helper.FileSystem where
module Ema.Helper.FileSystem
( filesMatching,
onChange,
FileAction (..),
)
where
import Control.Concurrent (threadDelay)
import Control.Exception (finally)
import Control.Monad.Logger
import Ema.App (MonadEma)
import System.Directory (canonicalizePath)
import System.FSNotify
( Event (..),
watchTree,
withManager,
)
import System.FilePath (makeRelative)
import System.FilePattern (FilePattern)
import System.FilePattern.Directory (getDirectoryFiles)
import UnliftIO (withRunInIO)
type FolderPath = FilePath
filesMatching :: FolderPath -> [FilePattern] -> IO [FilePath]
log :: MonadLogger m => LogLevel -> Text -> m ()
log = logWithoutLoc "Helper.FileSystem"
filesMatching :: MonadEma m => FolderPath -> [FilePattern] -> m [FilePath]
filesMatching parent' pats = do
parent <- canonicalizePath parent'
getDirectoryFiles parent pats
parent <- liftIO $ canonicalizePath parent'
log LevelInfo $ toText $ "Traversing " <> parent <> " for files matching " <> show pats
liftIO $ getDirectoryFiles parent pats
data FileAction = Update | Delete
deriving (Eq, Show)
onChange :: FolderPath -> (FilePath -> FileAction -> IO ()) -> IO ()
onChange :: forall m. MonadEma m => FolderPath -> (FilePath -> FileAction -> m ()) -> m ()
onChange parent' f = do
-- NOTE: It is important to use canonical path, because this will allow us to
-- transform fsnotify event's (absolute) path into one that is relative to
-- @parent'@ (as passed by user), which is what @f@ will expect.
parent <- canonicalizePath parent'
withManager $ \mgr -> do
stop <- watchTree mgr parent (const True) $ \event -> do
print event
parent <- liftIO $ canonicalizePath parent'
withManagerM $ \mgr -> do
log LevelInfo $ toText $ "Monitoring " <> parent <> " for changes"
stop <- watchTreeM mgr parent (const True) $ \event -> do
log LevelDebug $ show event
let rel = makeRelative parent
case event of
Added (rel -> fp) _ _ -> f fp Update
Modified (rel -> fp) _ _ -> f fp Update
Removed (rel -> fp) _ _ -> f fp Delete
Unknown (rel -> fp) _ _ -> f fp Delete
threadDelay maxBound
`finally` stop
liftIO $ threadDelay maxBound `finally` stop
withManagerM ::
MonadEma m =>
(WatchManager -> m a) ->
m a
withManagerM f = do
withRunInIO $ \run ->
withManager $ \mgr -> run (f mgr)
watchTreeM ::
forall m.
MonadEma m =>
WatchManager ->
FilePath ->
ActionPredicate ->
(Event -> m ()) ->
m StopListening
watchTreeM wm fp pr f =
withRunInIO $ \run ->
watchTree wm fp pr $ \evt -> run (f evt)

View File

@ -6,10 +6,11 @@ module Ema.Server where
import Control.Concurrent.Async (race)
import Control.Exception (catch, try)
import Control.Monad.Logger
import Data.LVar (LVar)
import qualified Data.LVar as LVar
import qualified Data.Text as T
import Ema.Class (Ema (decodeRoute, staticAssets))
import Ema.Class (Ema (decodeRoute, staticAssets), MonadEma)
import GHC.IO.Unsafe (unsafePerformIO)
import NeatInterpolation (text)
import qualified Network.HTTP.Types as H
@ -20,82 +21,98 @@ import qualified Network.Wai.Middleware.Static as Static
import Network.WebSockets (ConnectionException)
import qualified Network.WebSockets as WS
import Relude.Extra.Foldable1 (foldl1')
import Text.Printf (printf)
runServerWithWebSocketHotReload ::
forall model route.
(Ema model route, Show route) =>
forall model route m.
(Ema model route, Show route, MonadEma m) =>
Int ->
LVar model ->
(model -> route -> LByteString) ->
IO ()
m ()
runServerWithWebSocketHotReload port model render = do
let settings = Warp.setPort port Warp.defaultSettings
Warp.runSettings settings $ assetsMiddleware $ WaiWs.websocketsOr WS.defaultConnectionOptions wsApp httpApp
logger <- askLoggerIO
logInfoN $ "Launching Ema at http://localhost:" <> show port
liftIO $
Warp.runSettings settings $
assetsMiddleware $
WaiWs.websocketsOr
WS.defaultConnectionOptions
(flip runLoggingT logger . wsApp)
(httpApp logger)
where
wsApp pendingConn = do
conn :: WS.Connection <- WS.acceptRequest pendingConn
WS.withPingThread conn 30 (pure ()) $ do
subId <- LVar.addListener model
let log s = putTextLn $ "[" <> show subId <> "] :: " <> s
log "ws:connected"
let askClientForRoute = do
msg :: Text <- WS.receiveData conn
pure $
msg
& pathInfoFromWsMsg
& routeFromPathInfo
& fromMaybe (error "invalid route from ws")
loop = do
-- Notice that we @askClientForRoute@ in succession twice here.
-- The first route will be the route the client intends to observe
-- for changes on. The second route, *if* it is sent, indicates
-- that the client wants to *switch* to that route. This proecess
-- repeats ad infinitum: i.e., the third route is for observing
-- changes, the fourth route is for switching to, and so on.
watchingRoute <- askClientForRoute
log $ "[Watch]: <~~ " <> show watchingRoute
-- Listen *until* either we get a new value, or the client requests
-- to switch to a new route.
race (LVar.listenNext model subId) askClientForRoute >>= \case
Left newHtml -> do
-- The page the user is currently viewing has changed. Send
-- the new HTML to them.
WS.sendTextData conn $ renderWithEmaHtmlShims newHtml watchingRoute
log $ "[Watch]: ~~> " <> show watchingRoute
loop
Right nextRoute -> do
-- The user clicked on a route link; send them the HTML for
-- that route this time, ignoring what we are watching
-- currently (we expect the user to initiate a watch route
-- request immediately following this).
log $ "[Switch]: <~~ " <> show nextRoute
html <- LVar.get model
WS.sendTextData conn $ renderWithEmaHtmlShims html nextRoute
log $ "[Switch]: ~~> " <> show nextRoute
loop
try loop >>= \case
Right () -> pure ()
Left (err :: ConnectionException) -> do
log $ "ws:error " <> show err
LVar.removeListener model subId
conn :: WS.Connection <- lift $ WS.acceptRequest pendingConn
logger <- askLoggerIO
lift $
WS.withPingThread conn 30 (pure ()) $
flip runLoggingT logger $ do
subId <- LVar.addListener model
let log s = logDebugNS (toText @String $ printf "WS.Client.%.2d" subId) s
log "Connected"
let askClientForRoute = do
msg :: Text <- WS.receiveData conn
pure $
msg
& pathInfoFromWsMsg
& routeFromPathInfo
& fromMaybe (error "invalid route from ws")
loop = flip runLoggingT logger $ do
-- Notice that we @askClientForRoute@ in succession twice here.
-- The first route will be the route the client intends to observe
-- for changes on. The second route, *if* it is sent, indicates
-- that the client wants to *switch* to that route. This proecess
-- repeats ad infinitum: i.e., the third route is for observing
-- changes, the fourth route is for switching to, and so on.
watchingRoute <- liftIO askClientForRoute
log $ "<~~ " <> show watchingRoute
-- Listen *until* either we get a new value, or the client requests
-- to switch to a new route.
liftIO $ do
race (LVar.listenNext model subId) askClientForRoute >>= \res -> flip runLoggingT logger $ case res of
Left newHtml -> do
-- The page the user is currently viewing has changed. Send
-- the new HTML to them.
liftIO $ WS.sendTextData conn $ renderWithEmaHtmlShims newHtml watchingRoute
log $ " ~~> " <> show watchingRoute
lift loop
Right nextRoute -> do
-- The user clicked on a route link; send them the HTML for
-- that route this time, ignoring what we are watching
-- currently (we expect the user to initiate a watch route
-- request immediately following this).
log $ "[Switch]: <~~ " <> show nextRoute
html <- LVar.get model
liftIO $ WS.sendTextData conn $ renderWithEmaHtmlShims html nextRoute
log $ "[Switch]: ~~> " <> show nextRoute
lift loop
liftIO (try loop) >>= \case
Right () -> pure ()
Left (err :: ConnectionException) -> do
log $ "ws:error " <> show err
LVar.removeListener model subId
assetsMiddleware = do
case nonEmpty (staticAssets $ Proxy @route) of
Nothing -> id
Just assets ->
Just topLevelPaths ->
let assetPolicy :: Static.Policy =
foldl1' (Static.<|>) $ Static.hasPrefix <$> assets
foldl1' (Static.<|>) $ Static.hasPrefix <$> topLevelPaths
in Static.staticPolicy assetPolicy
httpApp req f = do
let mr = routeFromPathInfo (Wai.pathInfo req)
putStrLn $ "[http] " <> show mr
(status, v) <- case mr of
Nothing ->
pure (H.status404, "No route")
Just r -> do
val <- LVar.get model
let html = renderCatchingErrors val r
pure (H.status200, html <> emaStatusHtml <> wsClientShim)
f $ Wai.responseLBS status [(H.hContentType, "text/html")] v
httpApp logger req f = do
flip runLoggingT logger $ do
let path = Wai.pathInfo req
mr = routeFromPathInfo path
logInfoNS "HTTP" $ show path <> " as " <> show mr
(status, v) <- case mr of
Nothing ->
pure (H.status404, "No route")
Just r -> do
val <- LVar.get model
let html = renderCatchingErrors val r
pure (H.status200, html <> emaStatusHtml <> wsClientShim)
liftIO $ f $ Wai.responseLBS status [(H.hContentType, "text/html")] v
renderWithEmaHtmlShims m r =
renderCatchingErrors m r <> emaStatusHtml
renderCatchingErrors m r =