All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.youi.app.ServerApplication.scala Maven / Gradle / Ivy

package io.youi.app

import fabric.parse.Json

import java.io.File
import io.youi.http._
import io.youi.http.content._
import io.youi.net._
import io.youi.server.Server
import io.youi.server.handler.{CachingManager, HttpHandler, HttpHandlerBuilder, SenderHandler}
import io.youi.stream.delta.Delta
import io.youi.stream.{HTMLParser, Selector, _}
import io.youi.{JavaScriptError, JavaScriptLog, http}
import net.sf.uadetector.UserAgentType
import net.sf.uadetector.service.UADetectorServiceFactory
import fabric.rw._
import reactify.Var
import scribe._
import scribe.data.MDC

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.{Await, Future}

trait ServerApplication extends YouIApplication with Server {
  override def isClient: Boolean = false

  override def isServer: Boolean = true

  lazy val cacheDirectory: Var[File] = Var(new File(System.getProperty("user.home"), ".cache"))

  private lazy val userAgentParser = UADetectorServiceFactory.getResourceModuleParser

  protected def applicationBasePath: String = "app/application"

  private val fullOpt = s"$applicationBasePath.js.youi"
  private val fastOpt = s"$applicationBasePath-fastopt.js.youi"
  private val fullOptMap = s"$fullOpt.map"
  private val fastOptMap = s"$fastOpt.map"
  private val jsDeps = s"$applicationBasePath-jsdeps.js.youi"

  protected def applicationJSBasePath: String = "/app/application"
  def applicationJSPath: String = s"$applicationJSBasePath.js"
  def applicationJSMapPath: String = s"$applicationJSPath.map"
  def applicationJSDepsPath: String = s"$applicationJSBasePath-jsdeps.js"

  lazy val applicationJSContent: Content = Content.classPathOption(fullOpt).getOrElse(Content.classPath(fastOpt))
  lazy val applicationJSMapContent: Content = Content.classPathOption(fullOptMap).getOrElse(Content.classPath(fastOptMap))
  lazy val applicationJSDepsContent: Option[Content] = Content.classPathOption(jsDeps)

  protected def scriptPaths: List[String] = Nil
  protected def responseMap(httpConnection: HttpConnection): Map[String, String] = Map.empty

  override protected def init(): Future[Unit] = super.init().map { _ =>
    handler.resource { url =>
      val path = url.path.encoded match {
        case "/" => None
        case s if s.startsWith("/") => Some(s.substring(1))
        case s => Some(s)
      }
      path.flatMap(p => Content.classPathOption(s"assets/$p"))
    }
    handler.matcher(path.exact(path"/wrap-image")).handle { httpConnection =>
      Future.successful(
        httpConnection.modify { response =>
          val imageURL = httpConnection.request.url.param("src").getOrElse("")
          response.withContent(Content.string(
            s"""
              |
              |Wrap Image
              |
              |
              |
              |
              |""".stripMargin, ContentType.`text/html`))
        }
      )
    }
    if (logJavaScriptErrors) {
      handler.matcher(path.exact(logPath)).handle { httpConnection =>
        val content = httpConnection.request.content
        content match {
          case Some(requestContent) => requestContent match {
            case formData: FormDataContent => {
              val ip = httpConnection.request.originalSource
              val userAgentString = Headers.Request.`User-Agent`.value(httpConnection.request.headers).getOrElse("")
              val userAgent = userAgentParser.parse(userAgentString)

              // Error logging
              formData.stringOption("error").map(_.value).foreach { jsonString =>
                val jsError = Json.parse(jsonString).as[JavaScriptError]

                val exception = new JavaScriptException(
                  error = jsError,
                  userAgent = userAgent,
                  ip = ip,
                  request = httpConnection.request,
                  info = errorInfo(jsError, httpConnection)
                )
                if (logJavaScriptException(exception)) {
                  error(exception)
                }
              }

              // Message logging
              formData.stringOption("message").map(_.value).foreach { jsonString =>
                val log = Json.parse(jsonString).as[JavaScriptLog]

                MDC.contextualize("ip", ip.toString) {
                  MDC.contextualize("userAgent", userAgentString) {
                    scribe.info(s"[JS] ${log.message.trim}")
                  }
                }
              }
            }
            case otherContent => scribe.error(s"Unsupported content type: $otherContent (${otherContent.getClass.getName})")
          }
          case None => // Ignore
        }

        Future.successful(httpConnection.modify(_.withContent(Content.empty)))
      }
    }

    val lastModifiedManager = CachingManager.LastModified()

    // Serve up application.js
    handler.matcher(path.exact(applicationJSPath)).caching(lastModifiedManager).resource(applicationJSContent)

    // Serve up application.js.map
    handler.matcher(path.exact(applicationJSMapPath)).caching(lastModifiedManager).resource(applicationJSMapContent)

    // Serve up application-jsdeps.js (if available)
    applicationJSDepsContent.foreach { content =>
      handler.matcher(path.exact(applicationJSDepsPath)).caching(lastModifiedManager).resource(content)
    }
  }

  def addTemplate(lookup: String => Option[Content],
                  mappings: Set[HttpConnection => Option[Content]] = Set.empty,
                  excludeDotHTML: Boolean = true,
                  deltas: List[Delta] = Nil,
                  includeApplication: URL => Boolean = _ => true): HttpHandler = {
    // Serve up template files
    handler.priority(Priority.Low).handle { httpConnection =>
      if (httpConnection.response.content.isEmpty) {
        val url = httpConnection.request.url
        val fileName = url.path.decoded
        val mapped = mappings.flatMap(_ (httpConnection)).headOption
        val content = lookup(fileName).orElse { if (excludeDotHTML) {
          lookup(s"$fileName.html")
        } else {
          None
        }}
        mapped.orElse(content).map { content =>
          if (content.contentType == ContentType.`text/html`) {
            CachingManager.NotCached.handle(httpConnection)
            serveHTML(httpConnection, content, deltas, includeApplication(url))
          } else {
            CachingManager.LastModified().handle(httpConnection)
            Future.successful(httpConnection.modify(_.withContent(content)))
          }
        }.getOrElse(Future.successful(httpConnection))
      } else {
        Future.successful(httpConnection)
      }
    }
  }

  protected def errorInfo(error: JavaScriptError, httpConnection: HttpConnection): Map[String, String] = Map.empty

  protected def page(page: Page): Page = {
    handlers += page
    page
  }

  protected def logJavaScriptException(exception: JavaScriptException): Boolean = {
    val ua = exception.userAgent
    ua.getType != UserAgentType.ROBOT
  }

  implicit class AppHandlerBuilder(builder: HttpHandlerBuilder) {
    /**
      * Stores deltas on this connection for use serving HTML.
      *
      * @param function the function that takes in an HttpConnection and returns a list of Deltas.
      * @return HttpHandler that has already been added to the server
      */
    def deltas(function: HttpConnection => List[Delta]): HttpHandler = builder.handle { connection =>
      val d: List[Delta] = function(connection)
      connection.deltas ++= d
      Future.successful(connection)
    }

    def page(template: Content = ServerApplication.AppTemplate,
             deltas: List[Delta] = Nil,
             includeApplication: Boolean = true): HttpHandler = builder.handle { connection =>
      serveHTML(connection, template, deltas, includeApplication)
    }
  }

  override protected def handleInternal(connection: HttpConnection): Future[HttpConnection] = {
    super.handleInternal(connection).flatMap { c =>
      c.response.content match {
        case Some(content) if content.contentType == ContentType.`text/html` && c.deltas.nonEmpty => {
          serveHTML(c.modify(_.removeContent()), content, Nil, includeApplication = false)
        }
        case _ => Future.successful(c) // Ignore
      }
    }
  }

  def serveHTML(httpConnection: HttpConnection, content: Content, deltas: List[Delta], includeApplication: Boolean): Future[HttpConnection] = {
    val stream = content match {
      case c: FileContent => HTMLParser.cache(c.file)
      case c: URLContent => HTMLParser.cache(c.url)
      case c: StringContent => HTMLParser.cache(c.value)
    }
    val responseFields = responseMap(httpConnection).toList.map {
      case (name, value) => s""""""
    }
    val deltasList = httpConnection.deltas() ::: deltas
    val applicationDeltas = if (includeApplication) {
      val jsDeps = if (applicationJSDepsContent.nonEmpty) {
        s""""""
      } else {
        ""
      }
      List(
        Delta.InsertLastChild(Selector.ByTag("body"),
          s"""
             |${scriptPaths.map(p => s"""""").mkString("\n")}
             |${responseFields.mkString("\n")}
             |$jsDeps
             |
             |
           """.stripMargin
        )
      )
    } else {
      Nil
    }
    val d = applicationDeltas ::: deltasList
    val selector = httpConnection.request.url.param("selector").map(Selector.parse)
    val html = stream.stream(d, selector)
    httpConnection.deltas.clear()
    SenderHandler.handle(httpConnection, Content.string(html, ContentType.`text/html`), caching = CachingManager.NotCached)
  }

  // Creates a cached version of the URL and adds an explicit matcher to serve it
  override def cached(url: URL): String = {
    val path = url.asPath()
    val directory = cacheDirectory()
    val file = new File(directory, path)
    file.getParentFile.mkdirs()
    IO.stream(new java.net.URL(url.toString), file)
    val content = Content.file(file)
    handler.matcher(http.path.exact(path)).resource(content)
    path
  }
}

object ServerApplication {
  /**
    * Empty page template with overflow on the body disabled and viewport fixed to avoid zooming.
    */
  lazy val AppTemplate: Content = Content.string(
    """
      |
      |
      | 
      | 
      | 
      | 
      | 
      | 
      | 
      | 
      | 
      |
      |
      |
      |
    """.stripMargin.trim, ContentType.`text/html`)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy