ihp-ssc (empty) → 1.5.0
raw patch · 10 files changed
+568/−0 lines, 10 filesdep +aesondep +attoparsecdep +base
Dependencies added: aeson, attoparsec, base, basic-prelude, blaze-html, bytestring, ihp, ihp-hsx, ihp-log, megaparsec, string-conversions, text, wai, wai-request-params, websockets
Files
- IHP/ServerSideComponent/Controller/ComponentsController.hs +57/−0
- IHP/ServerSideComponent/ControllerFunctions.hs +58/−0
- IHP/ServerSideComponent/HtmlDiff.hs +131/−0
- IHP/ServerSideComponent/HtmlParser.hs +134/−0
- IHP/ServerSideComponent/RouterFunctions.hs +33/−0
- IHP/ServerSideComponent/Types.hs +45/−0
- IHP/ServerSideComponent/ViewFunctions.hs +24/−0
- LICENSE +21/−0
- changelog.md +12/−0
- ihp-ssc.cabal +53/−0
+ IHP/ServerSideComponent/Controller/ComponentsController.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE UndecidableInstances #-}+module IHP.ServerSideComponent.Controller.ComponentsController where++import IHP.ControllerPrelude+import IHP.ServerSideComponent.Types as SSC+import IHP.ServerSideComponent.ControllerFunctions as SSC++import qualified Data.Aeson as Aeson+import qualified IHP.Log as Log+import qualified Control.Exception as Exception+import Data.Typeable (typeOf)++instance (Component component controller, FromJSON controller, Typeable component) => WSApp (ComponentsController component) where+ initialState = ComponentsController++ run = do+ let state :: component = SSC.initialState+ instanceRef <- newIORef (ComponentInstance { state })+ let ?instanceRef = instanceRef++ let componentName = tshow (typeOf state)+ Log.info ("SSC: Component " <> componentName <> " connected")++ -- Handle componentDidMount with exception handling+ mountResult <- Exception.try (componentDidMount state)+ case mountResult of+ Left (e :: SomeException) -> do+ let errorText = tshow e+ Log.error ("SSC: componentDidMount failed for " <> componentName <> ": " <> errorText)+ SSC.sendError (SSCActionError { errorMessage = "Component initialization failed: " <> errorText })+ Right nextState -> do+ Log.debug ("SSC: Component " <> componentName <> " mounted")+ SSC.setState nextState++ forever do+ actionPayload :: LByteString <- receiveData++ let theAction = Aeson.eitherDecode @controller actionPayload++ case theAction of+ Right theAction -> do+ currentState <- SSC.getState++ -- Execute action with exception handling+ actionResult <- Exception.try (SSC.action currentState theAction)+ case actionResult of+ Left (e :: SomeException) -> do+ let errorText = tshow e+ Log.error ("SSC: Action failed for " <> componentName <> ": " <> errorText)+ SSC.sendError (SSCActionError { errorMessage = errorText })+ Right nextState -> do+ SSC.setState nextState+ Left parseError -> do+ let errorText = cs parseError+ Log.error ("SSC: Failed parsing action for " <> componentName <> ": " <> errorText)+ Log.debug ("SSC: Invalid payload: " <> tshow actionPayload)+ SSC.sendError (SSCParseError { errorMessage = errorText })
+ IHP/ServerSideComponent/ControllerFunctions.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE TemplateHaskell #-}+{-|+Module: IHP.ServerSideComponent.ControllerFunctions+Copyright: (c) digitally induced GmbH, 2021+-}+module IHP.ServerSideComponent.ControllerFunctions where++import IHP.Prelude+import IHP.ControllerPrelude+import IHP.ServerSideComponent.Types as SSC++import qualified Network.WebSockets as WebSocket+import qualified Text.Blaze.Html.Renderer.Text as Blaze++import qualified Data.Aeson as Aeson+import qualified Data.Aeson.TH as Aeson++import IHP.ServerSideComponent.HtmlParser+import IHP.ServerSideComponent.HtmlDiff++import qualified IHP.Log as Log++$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''Attribute)+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''AttributeOperation)+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''Node)+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''NodeOperation)+$(Aeson.deriveJSON Aeson.defaultOptions { sumEncoding = defaultTaggedObject { tagFieldName = "type" }} ''SSCError)++setState :: (?instanceRef :: IORef (ComponentInstance state), ?connection :: WebSocket.Connection, Component state action, ?context :: ControllerContext, ?request :: Request) => state -> IO ()+setState state = do+ oldState <- (.state) <$> readIORef ?instanceRef+ let oldHtml = oldState+ |> SSC.render+ |> Blaze.renderHtml+ |> cs+ let newHtml = state+ |> SSC.render+ |> Blaze.renderHtml+ |> cs++ modifyIORef' ?instanceRef (\componentInstance -> componentInstance { state })++ case diffHtml oldHtml newHtml of+ Left parseError -> do+ let errorText = tshow parseError+ Log.error ("SSC HTML diff failed: " <> errorText)+ sendError (SSCDiffError { errorMessage = errorText })+ Right patches -> sendTextData (Aeson.encode patches)++-- | Send an error message to the client+sendError :: (?connection :: WebSocket.Connection) => SSCError -> IO ()+sendError error = sendTextData (Aeson.encode error)+++getState :: (?instanceRef :: IORef (ComponentInstance state)) => IO state+getState = (.state) <$> readIORef ?instanceRef++deriveSSC = Aeson.deriveJSON Aeson.defaultOptions { allNullaryToStringTag = False, sumEncoding = defaultTaggedObject { tagFieldName = "action", contentsFieldName = "payload" }}
+ IHP/ServerSideComponent/HtmlDiff.hs view
@@ -0,0 +1,131 @@+{-|+Module: IHP.ServerSideComponent.HtmlDiff+Copyright: (c) digitally induced GmbH, 2021+Description: Provides differences and patchsets between two HTML fragments+-}+module IHP.ServerSideComponent.HtmlDiff where++import IHP.Prelude+import IHP.ServerSideComponent.HtmlParser+import qualified Data.Text as Text+import Text.Megaparsec.Error (ParseErrorBundle)+import Data.Void (Void)++data NodeOperation+ = UpdateTextContent { textContent :: !Text, path :: ![Int] }+ | ReplaceNode { oldNode :: !Node, newNode :: !Node, newNodeHtml :: !Text, path :: ![Int] }+ | UpdateNode { attributeOperations :: ![AttributeOperation], path :: ![Int] }+ | UpdateComment { comment :: !Text, path :: ![Int] }+ | UpdatePreEscapedTextNode { textContent :: !Text, path :: ![Int] }+ | DeleteNode { node :: !Node, path :: ![Int] }+ | CreateNode { html :: !Text, path :: ![Int] }+ deriving (Eq, Show)++data AttributeOperation+ = UpdateAttribute { attributeName :: !Text, attributeValue :: !Text }+ | AddAttribute { attributeName :: !Text, attributeValue :: !Text }+ | DeleteAttribute { attributeName :: !Text }+ deriving (Eq, Show)++diffHtml :: Text -> Text -> Either (ParseErrorBundle Text Void) [NodeOperation]+diffHtml a b = do+ nodeA <- parseHtml a+ nodeB <- parseHtml b++ let ?oldHtml = a+ let ?newHtml = b++ pure (diffNodes nodeA nodeB)++type Path = [Int]++diffNodes :: (?oldHtml :: text, ?newHtml :: Text) => Node -> Node -> [NodeOperation]+diffNodes = diffNodes' []++diffNodes' :: (?oldHtml :: text, ?newHtml :: Text) => Path -> Node -> Node -> [NodeOperation]+diffNodes' path TextNode { textContent = oldTextContent } TextNode { textContent = newTextContent } =+ if oldTextContent == newTextContent+ then []+ else [UpdateTextContent { textContent = newTextContent, path }]+diffNodes' path CommentNode { comment = oldComment } CommentNode { comment = newComment } =+ if oldComment == newComment+ then []+ else [UpdateComment { comment = newComment, path }]+diffNodes' path NoRenderCommentNode NoRenderCommentNode = []+diffNodes' path PreEscapedTextNode { textContent = oldTextContent } PreEscapedTextNode { textContent = newTextContent } =+ if oldTextContent == newTextContent+ then []+ else [UpdatePreEscapedTextNode { textContent = newTextContent, path }]+diffNodes' path oldNode@(Node { tagName = oldTagName, attributes = oldAttributes, children = oldChildren }) newNode@(Node { tagName = newTagName, attributes = newAttributes, children = newChildren }) =+ if oldTagName == newTagName+ then+ let+ attributeOperations = diffAttributes oldAttributes newAttributes+ childrenNodeOperations = diffNodes' path Children { children = oldChildren } Children { children = newChildren }+ in concat [+ if isEmpty attributeOperations+ then []+ else [UpdateNode { attributeOperations, path }]+ , childrenNodeOperations+ ]++ else [ReplaceNode { oldNode, newNode, newNodeHtml = nodeOuterHtml newNode ?newHtml, path }]+diffNodes' path Children { children = oldChildren } Children { children = newChildren } =+ let+ patchElements :: [Node] -> [Node] -> Int -> [NodeOperation]+ patchElements (new:nextNewNode:newRest) (old:oldRest) !index | (not $ new `isNodeEqIgnoringPosition` old) && (old `isNodeEqIgnoringPosition` nextNewNode) = [ CreateNode { html = nodeOuterHtml new ?newHtml, path = (index:path) } ] <> (patchElements (newRest) (oldRest) (index + 2)) -- [A, C <old>] -> [A, B <new>, C <nextNewNode>]+ patchElements (new:newRest) (old:nextOld:oldRest) !index | (not $ new `isNodeEqIgnoringPosition` old) && (new `isNodeEqIgnoringPosition` nextOld) = [ DeleteNode { node = old, path = (index:path) } ] <> (patchElements (newRest) (oldRest) (index + 1)) -- [A, B <old>, C <nextOldNode> ] -> [A, C <new>]+ patchElements (new:newRest) (old:oldRest) !index = (diffNodes' (index:path) old new) <> (patchElements newRest oldRest (index + 1))+ patchElements (new:newRest) [] !index = [ CreateNode { html = nodeOuterHtml new ?newHtml, path = (index:path) } ] <> (patchElements newRest [] (index + 1))+ patchElements [] (old:oldRest) !index = [ DeleteNode { node = old, path = (index:path) } ] <> (patchElements [] oldRest (index + 1))+ patchElements [] [] _ = []+ in+ patchElements newChildren oldChildren 0+diffNodes' path oldNode newNode = [ReplaceNode { oldNode, newNode, newNodeHtml = nodeOuterHtml newNode ?newHtml, path }]+++diffAttributes :: [Attribute] -> [Attribute] -> [AttributeOperation]+diffAttributes old new = addOrUpdateAttributes <> deleteAttributes+ where+ addOrUpdateAttributes :: [AttributeOperation]+ addOrUpdateAttributes =+ new+ |> map (matchAttribute old)+ |> zip new+ |> mapMaybe diffMatchedAttribute++ deleteAttributes :: [AttributeOperation]+ deleteAttributes =+ old+ |> map (matchAttribute new)+ |> zip old+ |> filter (\(_, newAttribute) -> isNothing newAttribute)+ |> map (\(Attribute { attributeName }, _) -> DeleteAttribute { attributeName })++ diffMatchedAttribute :: (Attribute, Maybe Attribute) -> Maybe AttributeOperation+ diffMatchedAttribute (Attribute { attributeName, attributeValue = newValue }, Just Attribute { attributeValue = oldValue }) | newValue == oldValue = Nothing+ diffMatchedAttribute (Attribute { attributeName, attributeValue = newValue }, Just Attribute { attributeValue = oldValue }) = Just UpdateAttribute { attributeName, attributeValue = newValue }+ diffMatchedAttribute (Attribute { attributeName, attributeValue }, Nothing) = Just AddAttribute { attributeName, attributeValue }++ -- | Finds an attribute in 'old' with the same attribute name+ matchAttribute :: [Attribute] -> Attribute -> Maybe Attribute+ matchAttribute attributes Attribute { attributeName } = find (\Attribute { attributeName = attributeName' } -> attributeName == attributeName' ) attributes++-- | Grabs the entire HTML string corresponding to the node boundaries.+--+-- Node boundaries are only stored for 'Node'. Other nodes ('TextNode', etc) don't store start/end offset, so we render+-- them by ourselves.+nodeOuterHtml :: Node -> Text -> Text+nodeOuterHtml Node { startOffset, endOffset } html = html+ |> Text.drop startOffset+ |> Text.take (endOffset - startOffset)+-- Assuming chars are already escaped, because that's what HSX produces+nodeOuterHtml TextNode { textContent } _ = textContent+nodeOuterHtml PreEscapedTextNode { textContent } _ = textContent+nodeOuterHtml Children { children } html = mconcat $ map (`nodeOuterHtml` html) children+nodeOuterHtml CommentNode { comment } _ = "<!--" <> comment <> "-->"+nodeOuterHtml NoRenderCommentNode _ = "" -- HSX comments {- ... -} are not rendered++isNodeEqIgnoringPosition :: Node -> Node -> Bool+isNodeEqIgnoringPosition a@(Node {}) b@(Node {}) = (a { startOffset = 0, endOffset = 0 }) == (b { startOffset = 0, endOffset = 0 })+isNodeEqIgnoringPosition a b = a == b
+ IHP/ServerSideComponent/HtmlParser.hs view
@@ -0,0 +1,134 @@+{-|+Module: IHP.ServerSideComponent.HtmlParser+Copyright: (c) digitally induced GmbH, 2021+Description: Used by serverside DOM diff+-}+module IHP.ServerSideComponent.HtmlParser+( parseHtml+, Node (..)+, Attribute (..)+) where++import CorePrelude+import Text.Megaparsec+import Text.Megaparsec.Char+import Data.Void+import qualified Data.Char as Char+import Data.String.Conversions++data Attribute = Attribute+ { attributeName :: !Text+ , attributeValue :: !Text+ } deriving (Eq, Show)++data Node = Node { tagName :: !Text, attributes :: ![Attribute], children :: ![Node], startOffset :: Int, endOffset :: Int }+ | TextNode { textContent :: !Text } -- ^ Note: doesn't unescape chars like <+ | PreEscapedTextNode { textContent :: !Text } -- ^ Used in @script@ or @style@ bodies+ | Children { children :: ![Node] }+ | CommentNode { comment :: !Text } -- ^ A Comment that is rendered in the final HTML+ | NoRenderCommentNode -- ^ A comment that is not rendered in the final HTML+ deriving (Eq, Show)++parseHtml :: Text -> Either (ParseErrorBundle Text Void) Node+parseHtml code = runParser (parser) "" code++type Parser = Parsec Void Text++parser :: Parser Node+parser = do+ space+ node <- parseChildren <|> parseElement+ space+ eof+ pure node++parseElement = try parseNoRenderComment <|> try parseComment <|> try parseNormalElement <|> try parseSelfClosingElement++parseChildren = Children <$> many parseChild++parseSelfClosingElement = do+ startOffset <- getOffset+ _ <- char '<'+ name <- parseElementName+ attributes <- parseNodeAttributes (string ">" <|> string "/>")+ endOffset <- getOffset+ space+ pure (Node name attributes [] startOffset endOffset)++parseNormalElement = do+ startOffset <- getOffset+ (name, attributes) <- parseOpeningElement+ let parsePreEscapedTextChildren = do+ let closingElement = "</" <> name <> ">"+ text <- cs <$> manyTill anySingle (string closingElement)+ pure [PreEscapedTextNode text]+ let parseNormalChildren = (space >> (manyTill (try parseChild) (try (space >> parseClosingElement name))))++ children <- case name of+ "script" -> parsePreEscapedTextChildren+ "style" -> parsePreEscapedTextChildren+ otherwise -> parseNormalChildren+ endOffset <- getOffset+ pure (Node name attributes children startOffset endOffset)++parseOpeningElement = do+ char '<'+ name <- parseElementName+ space+ attributes <- parseNodeAttributes (char '>')+ pure (name, attributes)++parseComment :: Parser Node+parseComment = do+ string "<!--"+ body :: String <- manyTill (satisfy (const True)) (string "-->")+ space+ pure (CommentNode (cs body))++parseNoRenderComment :: Parser Node+parseNoRenderComment = do+ string "{-"+ manyTill (satisfy (const True)) (string "-}")+ space+ pure NoRenderCommentNode++parseNodeAttributes :: Parser a -> Parser [Attribute]+parseNodeAttributes end = manyTill parseNodeAttribute end++parseNodeAttribute = do+ attributeName <- parseAttributeName+ space++ let parseAttributeValue = do+ _ <- char '='+ space+ attributeValue <- parseQuotedValue+ space+ pure attributeValue++ attributeValue <- fromMaybe "" <$> optional parseAttributeValue+ pure Attribute { attributeName, attributeValue }++parseAttributeName :: Parser Text+parseAttributeName = takeWhile1P Nothing (\c -> Char.isAlphaNum c || c == '-')++parseQuotedValue :: Parser Text+parseQuotedValue = between (char '"') (char '"') (takeWhileP Nothing (\c -> c /= '\"'))++parseClosingElement name = do+ _ <- string ("</" <> name)+ space+ char ('>')+ pure ()++parseChild = parseElement <|> try (parseElement) <|> parseText++parseText :: Parser Node+parseText = TextNode <$> takeWhile1P (Just "text") (\c -> c /= '<' && c /= '>')++parseElementName :: Parser Text+parseElementName = do+ name <- takeWhile1P (Just "identifier") (\c -> Char.isAlphaNum c || c == '_' || c == '-')+ space+ pure name+
+ IHP/ServerSideComponent/RouterFunctions.hs view
@@ -0,0 +1,33 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-|+Module: IHP.ServerSideComponent.RouterFunctions+Copyright: (c) digitally induced GmbH, 2021+-}+module IHP.ServerSideComponent.RouterFunctions where++import IHP.Prelude+import IHP.ServerSideComponent.Types+import qualified Data.Typeable as Typeable+import qualified Data.ByteString.Char8 as ByteString+import IHP.RouterSupport+import qualified Prelude+import IHP.ServerSideComponent.Controller.ComponentsController ()+import Data.Aeson+import IHP.ControllerSupport++routeComponent :: forall component controller application.+ ( Typeable component+ , Component component controller+ , FromJSON controller+ , InitControllerContext application+ , Typeable application+ , ?application :: application+ , ?request :: Request+ , ?respond :: Respond+ ) => ControllerRoute application+routeComponent = webSocketAppWithCustomPath @(ComponentsController component) @application ("SSC/" <> typeName)+ where+ typeName :: ByteString+ typeName = Typeable.typeOf (error "unreachable" :: component)+ |> Prelude.show+ |> ByteString.pack
+ IHP/ServerSideComponent/Types.hs view
@@ -0,0 +1,45 @@+{-|+Module: IHP.ServerSideComponent.Types+Description: Types & Data Structures for IHP SSC+Copyright: (c) digitally induced GmbH, 2021+-}+module IHP.ServerSideComponent.Types where++import IHP.ViewPrelude+import qualified Network.WebSockets as WebSocket+import GHC.Generics (Generic)++class Component state action | state -> action where+ initialState :: state+ render :: state -> Html+ action ::+ ( ?instanceRef :: IORef (ComponentInstance state)+ , ?connection :: WebSocket.Connection+ , ?context :: ControllerContext+ , ?modelContext :: ModelContext+ ) => state -> action -> IO state++ componentDidMount ::+ ( ?instanceRef :: IORef (ComponentInstance state)+ , ?connection :: WebSocket.Connection+ , ?context :: ControllerContext+ , ?modelContext :: ModelContext+ ) => state -> IO state+ componentDidMount state = pure state++data ComponentsController components+ = ComponentsController+ deriving (Eq, Show, Data)++data ComponentInstance state+ = ComponentInstance { state :: state } -- ^ If you wondered why the current rendered HTML doesn't need to be stored here for later diffing it: As our render functions are pure we can just re-render the HTML based on the state when we want to do our diffing++instance (SetField "state" (ComponentInstance state) state) where+ setField state componentInstance = componentInstance { state }++-- | Error types for SSC operations sent to the client+data SSCError+ = SSCDiffError { errorMessage :: !Text }+ | SSCActionError { errorMessage :: !Text }+ | SSCParseError { errorMessage :: !Text }+ deriving (Eq, Show, Generic)
+ IHP/ServerSideComponent/ViewFunctions.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-|+Module: IHP.ServerSideComponent.ViewFunctions+Copyright: (c) digitally induced GmbH, 2021+-}+module IHP.ServerSideComponent.ViewFunctions where++import IHP.Prelude+import IHP.ViewSupport+import IHP.ServerSideComponent.Types+import IHP.HSX.QQ (hsx)++import qualified Data.Typeable as Typeable++component :: forall component action. (Component component action, Typeable component) => Html+component = componentFromState (initialState @component)++componentFromState :: forall component action. (Component component action, Typeable component) => component -> Html+componentFromState state = [hsx|<div class="ihp-ssc" data-path={path}>{render state}</div>|]+ where+ path = "/SSC/" <> typeName+ typeName = (undefined :: component)+ |> Typeable.typeOf+ |> show
+ LICENSE view
@@ -0,0 +1,21 @@+The MIT License (MIT)++Copyright (c) 2020 digitally induced GmbH++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ changelog.md view
@@ -0,0 +1,12 @@+# Changelog for `ihp-ssc`++## v1.5.0++- Add `SSCError` type for structured error reporting (SSCDiffError, SSCActionError, SSCParseError)+- Add proper error handling and logging in SSC controllers using IHP.Log+- Fix missing `NoRenderCommentNode` pattern in `nodeOuterHtml`+- Fix critical bugs and add reconnection support in ihp-ssc.js++## v1.3.0++- Initial release as a standalone package
+ ihp-ssc.cabal view
@@ -0,0 +1,53 @@+cabal-version: 2.2+name: ihp-ssc+version: 1.5.0+synopsis: Server Side Components for IHP+description: IHP Server-Side Components provide a toolkit for building interactive client-side functionality without needing to write too much JavaScript.+license: MIT+license-file: LICENSE+author: digitally induced GmbH+maintainer: hello@digitallyinduced.com+homepage: https://ihp.digitallyinduced.com/+bug-reports: https://github.com/digitallyinduced/ihp/issues+copyright: (c) digitally induced GmbH+category: Web, IHP+build-type: Simple+extra-source-files: changelog.md++source-repository head+ type: git+ location: https://github.com/digitallyinduced/ihp++library+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ , NoImplicitPrelude+ , ImplicitParams+ , DisambiguateRecordFields+ , DuplicateRecordFields+ , OverloadedLabels+ , DataKinds+ , QuasiQuotes+ , TypeFamilies+ , PackageImports+ , RecordWildCards+ , DefaultSignatures+ , FunctionalDependencies+ , PartialTypeSignatures+ , BlockArguments+ , LambdaCase+ , TemplateHaskell+ , OverloadedRecordDot+ , DeepSubsumption+ ghc-options: -Werror=incomplete-patterns -Werror=unused-imports -Werror=missing-fields+ hs-source-dirs: .+ build-depends: ihp, ihp-log, aeson, megaparsec, bytestring, wai, websockets, ihp-hsx, base >= 4.17.0 && < 4.22, string-conversions, basic-prelude, text, blaze-html, attoparsec, wai-request-params+ exposed-modules:+ IHP.ServerSideComponent.Types+ , IHP.ServerSideComponent.ViewFunctions+ , IHP.ServerSideComponent.RouterFunctions+ , IHP.ServerSideComponent.ControllerFunctions+ , IHP.ServerSideComponent.Controller.ComponentsController+ , IHP.ServerSideComponent.HtmlDiff+ , IHP.ServerSideComponent.HtmlParser