module Main (main, mainInParentDirectory, mainInProjectDirectory) where import ClassyPrelude import qualified System.Process as Process import IHP.HaskellSupport import qualified Data.ByteString.Char8 as ByteString import Control.Concurrent (myThreadId, threadDelay) import System.Exit import System.Posix.Signals import IHP.IDE.Types import IHP.IDE.Postgres import IHP.IDE.StatusServer import IHP.IDE.LiveReloadNotificationServer import IHP.IDE.PortConfig import IHP.IDE.ToolServer import IHP.IDE.ToolServer.Types import qualified IHP.SchemaCompiler as SchemaCompiler import qualified IHP.EnvVar as EnvVar import Data.String.Conversions (cs) import qualified IHP.Telemetry as Telemetry import qualified IHP.Version as Version import qualified IHP.Log.Types as Log import qualified IHP.Log as Log import Data.Default (def, Default (..)) import qualified IHP.IDE.CodeGen.MigrationGenerator as MigrationGenerator import Main.Utf8 (withUtf8) import qualified IHP.FrameworkConfig as FrameworkConfig import qualified Control.Concurrent.Chan.Unagi as Queue import IHP.IDE.FileWatcher import qualified System.Environment as Env import qualified System.Directory as Directory import qualified Control.Exception.Safe as Exception import qualified Data.ByteString.Builder as ByteString import qualified Network.Socket as Socket mainInParentDirectory :: IO () mainInParentDirectory = do cwd <- Directory.getCurrentDirectory mainInProjectDirectory (cwd <> "/../") mainInProjectDirectory :: FilePath -> IO () mainInProjectDirectory projectDir = do cwd <- Directory.getCurrentDirectory withCurrentWorkingDirectory projectDir do Env.setEnv "IHP_LIB" (cwd <> "/ihp-ide/lib/IHP") Env.setEnv "TOOLSERVER_STATIC" (cwd <> "/ihp-ide/lib/IHP/static") Env.setEnv "IHP_STATIC" (cwd <> "/lib/IHP/static") mainWithOptions True withCurrentWorkingDirectory :: FilePath -> IO result -> IO result withCurrentWorkingDirectory workingDirectory callback = do cwd <- Directory.getCurrentDirectory Exception.bracket_ (Directory.setCurrentDirectory workingDirectory) (Directory.setCurrentDirectory cwd) callback main :: IO () main = mainWithOptions False mainWithOptions :: Bool -> IO () mainWithOptions wrapWithDirenv = withUtf8 do databaseNeedsMigration <- newIORef False portConfig <- findAvailablePortConfig ensureUserIsNotRoot -- Start the dev server in Debug mode by setting the env var DEBUG=1 -- Like: $ DEBUG=1 devenv up isDebugMode <- EnvVar.envOrDefault "DEBUG" False bracket (Log.newLogger def) (\logger -> logger.cleanup) \logger -> do (ghciInChan, ghciOutChan) <- Queue.newChan liveReloadClients <- newIORef mempty lastSchemaCompilerError <- newIORef Nothing let ?context = Context { portConfig, isDebugMode, logger, ghciInChan, ghciOutChan, wrapWithDirenv, liveReloadClients, lastSchemaCompilerError } -- Print IHP Version when in debug mode when isDebugMode (Log.debug ("IHP Version: " <> Version.ihpVersion)) ghciIsLoadingVar <- newIORef False reloadGhciVar :: MVar () <- newEmptyMVar withBuiltinOrDevenvPostgres \databaseIsReady postgresStandardOutput postgresErrorOutput -> do withStatusServer ghciIsLoadingVar \startStatusServer stopStatusServer statusServerStandardOutput statusServerErrorOutput statusServerClients -> do -- Compile Schema before loading the app tryCompileSchema reloadGhciVar startStatusServer let toolServerApplication = ToolServerApplication { postgresStandardOutput , postgresErrorOutput , appStandardOutput = statusServerStandardOutput , appErrorOutput = statusServerErrorOutput , appPort = portConfig.appPort , databaseNeedsMigration } void $ runConcurrently $ (,,,,,,) <$> Concurrently (updateDatabaseIsOutdated databaseNeedsMigration databaseIsReady) <*> Concurrently (runToolServer toolServerApplication liveReloadClients) <*> Concurrently (consumeGhciOutput statusServerStandardOutput statusServerErrorOutput statusServerClients) <*> Concurrently Telemetry.reportTelemetry <*> Concurrently (runFileWatcherWithDebounce (fileWatcherParams liveReloadClients databaseNeedsMigration databaseIsReady reloadGhciVar startStatusServer)) <*> Concurrently (runAppGhci ghciIsLoadingVar startStatusServer stopStatusServer statusServerStandardOutput statusServerErrorOutput statusServerClients reloadGhciVar) pure () fileWatcherParams liveReloadClients databaseNeedsMigration databaseIsReady reloadGhciVar startStatusServer = FileWatcherParams { onHaskellFileChanged = putMVar reloadGhciVar () , onSchemaChanged = concurrently_ (tryCompileSchema reloadGhciVar startStatusServer) (updateDatabaseIsOutdated databaseNeedsMigration databaseIsReady) , onAssetChanged = notifyAssetChange liveReloadClients } isUsingDevenv :: IO Bool isUsingDevenv = EnvVar.envOrDefault "IHP_DEVENV" False ghciArguments :: [String] ghciArguments = [ "-threaded" , "-fomit-interface-pragmas" , "-j" , "-O0" , "-package-env -" -- Do not load `~/.ghc/arch-os-version/environments/name file`, global packages interfere with our packages , "-ignore-dot-ghci" -- Ignore the global ~/.ghc/ghci.conf That file sometimes causes trouble (specifically `:set +c +s`) , "-ghci-script", ".ghci" -- Because the previous line ignored default ghci config file locations, we have to manual load our .ghci , "+RTS", "-A128m", "-n2m", "-H2m", "--nonmoving-gc", "-N" ] withGHCI :: (?context :: Context) => (Handle -> Handle -> Handle -> Process.ProcessHandle -> IO a) -> IO a withGHCI callback = do let params = (procDirenvAware "ghci" ghciArguments) { Process.std_in = Process.CreatePipe , Process.std_out = Process.CreatePipe , Process.std_err = Process.CreatePipe , Process.create_group = True } Process.withCreateProcess params \(Just input) (Just output) (Just error) processHandle -> callback input output error processHandle -- | Exit with an error if running as the root user -- -- When the dev server starts the postgres server, it will fail if run as root: -- -- > initdb: cannot be run as root -- -- This is a bit hard to debug, therefore we proactively fail early when run as root -- ensureUserIsNotRoot :: IO () ensureUserIsNotRoot = do username <- EnvVar.envOrDefault "USERNAME" ("" :: ByteString) when (username == "root") do ByteString.hPutStrLn stderr "Cannot be run as root: The IHP dev server cannot be run with the root user because we cannot start the postgres server with a root user.\n\nPlease run this with a normal user.\nIf you need help, join the IHP Slack: https://ihp.digitallyinduced.com/Slack" exitFailure initGHCICommands = [ -- The app is loaded by loading .ghci, which then loads applicationGhciConfig, which triggers a ':l Main.hs' ":set prompt \"\"" -- Disable the prompt as this caused output such as '[38;5;208mIHP>[m Ser[v3e8r; 5s;t2a0r8tmedI' instead of 'Server started' , "import qualified ClassyPrelude" ] runAppGhci :: (?context :: Context) => IORef Bool -> MVar () -> MVar (MVar ()) -> IORef [ByteString] -> IORef [ByteString] -> Clients -> MVar () -> IO () runAppGhci ghciIsLoadingVar startStatusServer stopStatusServer statusServerStandardOutput statusServerErrorOutput statusServerClients reloadGhciVar = do let isDebugMode = ?context.isDebugMode -- The app is using the `PORT` env variable for its web server let appPort :: Int = fromIntegral ?context.portConfig.appPort Env.setEnv "PORT" (show appPort) let withoutStatusServer callback = Exception.bracket_ (do isStoppedVar <- newEmptyMVar; putMVar stopStatusServer isStoppedVar; takeMVar isStoppedVar) (putMVar startStatusServer ()) callback let processResult inputHandle outputHandle errorHandle processHandle result = do hasSchemaCompilerError <- isJust <$> readIORef ?context.lastSchemaCompilerError -- This branch blocks until .hs file change happens case result of Left failed -> takeMVar reloadGhciVar Right _ | hasSchemaCompilerError -> takeMVar reloadGhciVar Right loaded -> do withoutStatusServer do withRunningApp ?context.portConfig.appPort inputHandle outputHandle errorHandle processHandle receiveAppOutput do takeMVar reloadGhciVar pure () writeIORef ghciIsLoadingVar True -- Clear logs in web ui clearStatusServer statusServerStandardOutput statusServerErrorOutput statusServerClients result <- refresh inputHandle outputHandle errorHandle receiveAppOutput -- reload app notifyHaskellChange ?context.liveReloadClients processResult inputHandle outputHandle errorHandle processHandle result withGHCI \inputHandle outputHandle errorHandle processHandle -> do writeIORef ghciIsLoadingVar True withLoadedApp inputHandle outputHandle errorHandle receiveAppOutput \result -> do processResult inputHandle outputHandle errorHandle processHandle result withLoadedApp :: (?context :: Context) => Handle -> Handle -> Handle -> (OutputLine -> IO ()) -> ((Either LByteString LByteString) -> IO a) -> IO a withLoadedApp inputHandle outputHandle errorHandle logLine callback = do outputVar :: MVar ByteString.Builder <- newMVar "" resultVar :: MVar Bool <- newEmptyMVar let readHandle handle logLine = race_ (readMVar resultVar) $ forever do line <- ByteString.hGetLine handle modifyMVar_ outputVar (\builder -> pure (builder <> "\n" <> ByteString.byteString line)) logLine line case line of line | "Failed," `isInfixOf` line -> putMVar resultVar False line | "modules loaded." `isInfixOf` line -> putMVar resultVar True _ -> pure () let main = do sendGhciCommands inputHandle initGHCICommands result <- takeMVar resultVar output <- takeMVar outputVar let resultArg = if result then Right (ByteString.toLazyByteString output) else Left (ByteString.toLazyByteString output) callback resultArg (result, _, _) <- runConcurrently $ (,,) <$> Concurrently main <*> Concurrently (readHandle outputHandle (\line -> logLine (StandardOutput line))) <*> Concurrently (readHandle errorHandle (\line -> logLine (ErrorOutput line))) pure result withRunningApp :: (?context :: Context) => Socket.PortNumber -> Handle -> Handle -> Handle -> Process.ProcessHandle -> (OutputLine -> IO ()) -> (IO a) -> IO a withRunningApp appPort inputHandle outputHandle errorHandle processHandle logLine callback = do outputVar :: MVar ByteString.Builder <- newMVar "" serverStarted :: MVar () <- newEmptyMVar serverStopped :: MVar () <- newEmptyMVar let readHandle handle logLine = race_ (readMVar serverStopped) (forever do line <- ByteString.hGetLine handle modifyMVar_ outputVar (\builder -> pure (builder <> "\n" <> ByteString.byteString line)) logLine line case line of line | "Server started" `isInfixOf` line -> putMVar serverStarted () _ -> pure () ) let startApp = do sendGhciCommand inputHandle "stopVar :: ClassyPrelude.MVar () <- ClassyPrelude.newEmptyMVar" sendGhciCommand inputHandle "app <- ClassyPrelude.async (ClassyPrelude.race_ (ClassyPrelude.takeMVar stopVar) (main `ClassyPrelude.catch` \\(e :: SomeException) -> IHP.Prelude.putStrLn (tshow e)))" let stopApp = do sendGhciCommand inputHandle "ClassyPrelude.putMVar stopVar ()" sendGhciCommand inputHandle "ClassyPrelude.cancel app" waitForPortAvailable appPort putMVar serverStopped () (result, _, _) <- runConcurrently $ (,,) <$> Concurrently (Exception.bracket_ startApp stopApp (do takeMVar serverStarted; callback)) <*> Concurrently (readHandle outputHandle (\line -> logLine (StandardOutput line))) <*> Concurrently (readHandle errorHandle (\line -> logLine (ErrorOutput line))) pure result waitForPortAvailable :: Socket.PortNumber -> IO () waitForPortAvailable port = do isAvailable <- isPortAvailable port unless isAvailable do putStrLn "waitForPortAvailable: wait" threadDelay 100000 waitForPortAvailable port refresh :: (?context :: Context) => Handle -> Handle -> Handle -> (OutputLine -> IO ()) -> IO (Either LByteString LByteString) refresh inputHandle outputHandle errorHandle logOutput = do outputVar :: MVar ByteString.Builder <- newMVar "" resultVar :: MVar Bool <- newEmptyMVar let readHandle handle logLine = race_ (readMVar resultVar) (forever do line <- ByteString.hGetLine handle modifyMVar_ outputVar (\builder -> pure (builder <> "\n" <> ByteString.byteString line)) logLine line case line of line | "Failed," `isInfixOf` line -> putMVar resultVar False line | "modules loaded." `isInfixOf` line -> putMVar resultVar True line | "cannot find object file for module" `isInfixOf` line -> do -- https://gitlab.haskell.org/ghc/ghc/-/issues/11596 sendGhciCommand inputHandle ":l" _ -> pure () ) let main = do sendGhciCommand inputHandle ":r" result <- takeMVar resultVar output <- takeMVar outputVar pure if result then Right (ByteString.toLazyByteString output) else Left (ByteString.toLazyByteString output) (result, _, _) <- runConcurrently $ (,,) <$> Concurrently main <*> Concurrently (readHandle outputHandle (\line -> logOutput (StandardOutput line))) <*> Concurrently (readHandle errorHandle (\line -> logOutput (ErrorOutput line))) pure result receiveAppOutput :: (?context :: Context) => OutputLine -> IO () receiveAppOutput line = do case line of StandardOutput output -> ByteString.putStrLn output ErrorOutput output -> ByteString.putStrLn output Queue.writeChan ?context.ghciInChan line checkDatabaseIsOutdated :: IO Bool checkDatabaseIsOutdated = do databaseUrl <- cs <$> FrameworkConfig.defaultDatabaseUrl diff <- MigrationGenerator.diffAppDatabase True databaseUrl pure (not (isEmpty diff)) updateDatabaseIsOutdated :: (?context :: Context) => IORef Bool -> MVar () -> IO () updateDatabaseIsOutdated databaseNeedsMigrationRef databaseIsReady = do result <- Exception.tryAny do readMVar databaseIsReady databaseNeedsMigration <- checkDatabaseIsOutdated writeIORef databaseNeedsMigrationRef databaseNeedsMigration case result of Left exception -> Log.error (tshow exception) Right _ -> pure () tryCompileSchema :: (?context :: Context) => MVar () -> MVar () -> IO () tryCompileSchema reloadGhciVar startStatusServer = do result <- Exception.tryAny SchemaCompiler.compile case result of Left exception -> do Log.error (tshow exception) receiveAppOutput (ErrorOutput (cs $ displayException exception)) writeIORef ?context.lastSchemaCompilerError (Just exception) putMVar reloadGhciVar () putMVar startStatusServer () Right _ -> do previouslyHadSchemaError <- isJust <$> readIORef ?context.lastSchemaCompilerError writeIORef ?context.lastSchemaCompilerError Nothing when previouslyHadSchemaError (putMVar reloadGhciVar ())