mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-24 21:26:58 +01:00
Serve hashed static resources
This commit is contained in:
parent
3278ddbd1c
commit
5381487d4f
8 changed files with 141 additions and 14 deletions
18
build.mill
18
build.mill
|
|
@ -60,15 +60,6 @@ object example extends ScalaCommon:
|
|||
def moduleDeps = Seq(scalive.zio)
|
||||
def mvnDeps = Seq(mvn"dev.optics::monocle-core:3.1.0", mvn"dev.zio::zio-logging:2.5.1")
|
||||
|
||||
def scaliveBundle = Task {
|
||||
os.copy(
|
||||
from = example.js.bundle().path,
|
||||
to = Task.dest / "public" / "app.js",
|
||||
createFolders = true
|
||||
)
|
||||
PathRef(Task.dest)
|
||||
}
|
||||
|
||||
def tailwindConfig = Task.Source(moduleDir / "tailwind.css")
|
||||
|
||||
def generateCss = Task {
|
||||
|
|
@ -81,17 +72,22 @@ object example extends ScalaCommon:
|
|||
).call().out.text()
|
||||
}
|
||||
|
||||
def cssResources = Task {
|
||||
def customResources = Task {
|
||||
os.write(
|
||||
target = Task.dest / "public" / "app.css",
|
||||
data = generateCss(),
|
||||
createFolders = true
|
||||
)
|
||||
os.copy(
|
||||
from = example.js.bundle().path,
|
||||
to = Task.dest / "public" / "app.js",
|
||||
createFolders = true
|
||||
)
|
||||
PathRef(Task.dest)
|
||||
}
|
||||
|
||||
def resources = Task {
|
||||
super.resources() :+ scaliveBundle() :+ cssResources()
|
||||
super.resources() :+ customResources()
|
||||
}
|
||||
|
||||
object js extends TypeScriptModule:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import zio.*
|
||||
import zio.http.*
|
||||
import zio.http.codec.PathCodec
|
||||
import zio.logging.ConsoleLoggerConfig
|
||||
import zio.logging.LogColor
|
||||
import zio.logging.LogFilter
|
||||
|
|
@ -47,7 +48,9 @@ object Example extends ZIOAppDefault:
|
|||
)
|
||||
)
|
||||
|
||||
val routes = liveRouter.routes @@ Middleware.serveResources(Path.empty / "static", "public")
|
||||
val routes =
|
||||
liveRouter.routes @@
|
||||
ServeHashedResourcesMiddleware(Path.empty / "static", "public")
|
||||
|
||||
override val run = Server.serve(routes).provide(Server.default)
|
||||
end Example
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
import scalive.*
|
||||
|
||||
object RootLayout:
|
||||
import java.nio.file.Paths
|
||||
|
||||
private val runtime = zio.Runtime.default
|
||||
|
||||
// TODO externalize config in common with ServeHashedResourcesMiddleware
|
||||
private lazy val resourceRoot: java.nio.file.Path =
|
||||
Option(getClass.getClassLoader.getResource("public"))
|
||||
.map(url => Paths.get(url.toURI))
|
||||
.getOrElse(throw new IllegalArgumentException("public resources directory not found"))
|
||||
|
||||
private def hashOrDie(rel: String): String =
|
||||
zio.Unsafe.unsafe { implicit u =>
|
||||
runtime.unsafe.run(StaticAssetHasher.hashedPath(rel, resourceRoot)).getOrThrowFiberFailure()
|
||||
}
|
||||
|
||||
private val hashedJs = s"/static/${hashOrDie("app.js")}"
|
||||
private val hashedCss = s"/static/${hashOrDie("app.css")}"
|
||||
|
||||
def apply(content: HtmlElement): HtmlElement =
|
||||
htmlRootTag(
|
||||
lang := "en",
|
||||
|
|
@ -12,12 +30,13 @@ object RootLayout:
|
|||
defer := true,
|
||||
phx.trackStatic := true,
|
||||
typ := "text/javascript",
|
||||
src := "/static/app.js"
|
||||
src := hashedJs
|
||||
),
|
||||
linkTag(phx.trackStatic := true, rel := "stylesheet", href := "/static/app.css"),
|
||||
linkTag(phx.trackStatic := true, rel := "stylesheet", href := hashedCss),
|
||||
titleTag("Scalive Example")
|
||||
),
|
||||
bodyTag(
|
||||
content
|
||||
)
|
||||
)
|
||||
end RootLayout
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package scalive
|
|||
import zio.*
|
||||
import zio.stream.*
|
||||
|
||||
// TODO implement all LiveView functions
|
||||
final case class LiveContext(staticChanged: Boolean)
|
||||
object LiveContext:
|
||||
def staticChanged: URIO[LiveContext, Boolean] = ZIO.serviceWith[LiveContext](_.staticChanged)
|
||||
|
|
|
|||
56
scalive/core/src/scalive/StaticAssetHasher.scala
Normal file
56
scalive/core/src/scalive/StaticAssetHasher.scala
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package scalive
|
||||
|
||||
import java.nio.file.{Files, Path, Paths}
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
import zio.*
|
||||
|
||||
object StaticAssetHasher:
|
||||
private val cache = new ConcurrentHashMap[String, (String, Long, Long)]()
|
||||
|
||||
private val defaultRoot: Path = Paths.get("public")
|
||||
|
||||
private def key(root: Path, rel: String): String =
|
||||
s"${root.toAbsolutePath.normalize().toString}::${rel}"
|
||||
|
||||
private def hex(bytes: Array[Byte]): String =
|
||||
bytes.map(b => f"$b%02x").mkString
|
||||
|
||||
def hashedPath(rel: String, root: Path = defaultRoot): Task[String] =
|
||||
ZIO.attempt {
|
||||
val leadingSlash = rel.startsWith("/")
|
||||
val relClean = if leadingSlash then rel.drop(1) else rel
|
||||
val keyStr = key(root, relClean)
|
||||
|
||||
val path = root.resolve(relClean).normalize()
|
||||
if !Files.exists(path) || !Files.isRegularFile(path) then
|
||||
throw new IllegalArgumentException(s"Static asset not found: $path")
|
||||
|
||||
val attrs = Files.readAttributes(path, classOf[java.nio.file.attribute.BasicFileAttributes])
|
||||
val lastMod = attrs.lastModifiedTime().toMillis
|
||||
val size = attrs.size()
|
||||
val cached = Option(cache.get(keyStr))
|
||||
val cachedHash = cached.collect { case (h, lm, sz) if lm == lastMod && sz == size => h }
|
||||
|
||||
val hashedName = cachedHash.getOrElse {
|
||||
val bytes = Files.readAllBytes(path)
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
val hash = hex(md5.digest(bytes)) // full 32 hex chars
|
||||
|
||||
val p = Paths.get(relClean)
|
||||
val fileName = p.getFileName.toString
|
||||
val dotIdx = fileName.lastIndexOf('.')
|
||||
val (stem, ext) = if dotIdx >= 0 then fileName.splitAt(dotIdx) else (fileName, "")
|
||||
val extOut = if ext.isEmpty then "" else ext
|
||||
val parentDir = Option(p.getParent).map(_.toString.replace('\\', '/'))
|
||||
val name = s"$stem-$hash$extOut"
|
||||
val fullName = parentDir.map(d => s"$d/$name").getOrElse(name)
|
||||
|
||||
cache.put(keyStr, (fullName, lastMod, size))
|
||||
fullName
|
||||
}
|
||||
|
||||
if leadingSlash then s"/$hashedName" else hashedName
|
||||
}
|
||||
end StaticAssetHasher
|
||||
21
scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala
Normal file
21
scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package scalive
|
||||
|
||||
import zio.*
|
||||
import zio.http.*
|
||||
|
||||
object ServeHashedResourcesMiddleware:
|
||||
|
||||
private val hashedStaticRegex = """/static/(.+)-([a-f0-9]{32})(\.[^./]+)?""".r
|
||||
|
||||
def apply(path: Path, resourcePrefix: String = "public"): Middleware[Any] =
|
||||
Middleware.interceptIncomingHandler(
|
||||
Handler.fromFunction[Request] { req =>
|
||||
val updated = req.url.path.encode match
|
||||
case hashedStaticRegex(base, _, ext) =>
|
||||
val targetPath =
|
||||
path.addLeadingSlash ++ Path.decode(s"/$base${Option(ext).getOrElse("")}")
|
||||
req.copy(url = req.url.copy(path = targetPath))
|
||||
case _ => req
|
||||
(updated, ())
|
||||
}
|
||||
) @@ Middleware.serveResources(path, resourcePrefix)
|
||||
1
scalive/zio/test/resources/public/app.js
Normal file
1
scalive/zio/test/resources/public/app.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log('test-asset');
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package scalive
|
||||
|
||||
import zio.*
|
||||
import zio.http.*
|
||||
import zio.test.*
|
||||
|
||||
object ServeHashedResourcesMiddlewareSpec extends ZIOSpecDefault:
|
||||
|
||||
private val routes =
|
||||
Routes.empty @@ ServeHashedResourcesMiddleware(Path.empty / "static", "public")
|
||||
|
||||
private def runRequest(path: String) =
|
||||
URL.decode(path) match
|
||||
case Left(error) => ZIO.die(error)
|
||||
case Right(url) => routes.runZIO(Request.get(url))
|
||||
|
||||
override def spec = suite("ServeHashedResourcesMiddlewareSpec")(
|
||||
test("serves resource for hashed path") {
|
||||
val hashed = "/static/app-0123456789abcdef0123456789abcdef.js"
|
||||
for
|
||||
res <- ZIO.scoped(runRequest(hashed))
|
||||
body <- res.body.asString
|
||||
yield assertTrue(res.status == Status.Ok, body.contains("test-asset"))
|
||||
},
|
||||
test("returns 404 for invalid hashed path") {
|
||||
val invalid = "/static/unknown-0123456789abcdef0123456789abcdef.js"
|
||||
for res <- ZIO.scoped(runRequest(invalid))
|
||||
yield assertTrue(res.status == Status.NotFound)
|
||||
}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue