Serve hashed static resources

This commit is contained in:
Paul-Henri Froidmont 2025-12-23 23:06:16 +01:00
parent 3278ddbd1c
commit 5381487d4f
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
8 changed files with 141 additions and 14 deletions

View file

@ -60,15 +60,6 @@ object example extends ScalaCommon:
def moduleDeps = Seq(scalive.zio) 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 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 tailwindConfig = Task.Source(moduleDir / "tailwind.css")
def generateCss = Task { def generateCss = Task {
@ -81,17 +72,22 @@ object example extends ScalaCommon:
).call().out.text() ).call().out.text()
} }
def cssResources = Task { def customResources = Task {
os.write( os.write(
target = Task.dest / "public" / "app.css", target = Task.dest / "public" / "app.css",
data = generateCss(), data = generateCss(),
createFolders = true createFolders = true
) )
os.copy(
from = example.js.bundle().path,
to = Task.dest / "public" / "app.js",
createFolders = true
)
PathRef(Task.dest) PathRef(Task.dest)
} }
def resources = Task { def resources = Task {
super.resources() :+ scaliveBundle() :+ cssResources() super.resources() :+ customResources()
} }
object js extends TypeScriptModule: object js extends TypeScriptModule:

View file

@ -1,5 +1,6 @@
import zio.* import zio.*
import zio.http.* import zio.http.*
import zio.http.codec.PathCodec
import zio.logging.ConsoleLoggerConfig import zio.logging.ConsoleLoggerConfig
import zio.logging.LogColor import zio.logging.LogColor
import zio.logging.LogFilter 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) override val run = Server.serve(routes).provide(Server.default)
end Example end Example

View file

@ -1,6 +1,24 @@
import scalive.* import scalive.*
object RootLayout: 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 = def apply(content: HtmlElement): HtmlElement =
htmlRootTag( htmlRootTag(
lang := "en", lang := "en",
@ -12,12 +30,13 @@ object RootLayout:
defer := true, defer := true,
phx.trackStatic := true, phx.trackStatic := true,
typ := "text/javascript", 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") titleTag("Scalive Example")
), ),
bodyTag( bodyTag(
content content
) )
) )
end RootLayout

View file

@ -3,6 +3,7 @@ package scalive
import zio.* import zio.*
import zio.stream.* import zio.stream.*
// TODO implement all LiveView functions
final case class LiveContext(staticChanged: Boolean) final case class LiveContext(staticChanged: Boolean)
object LiveContext: object LiveContext:
def staticChanged: URIO[LiveContext, Boolean] = ZIO.serviceWith[LiveContext](_.staticChanged) def staticChanged: URIO[LiveContext, Boolean] = ZIO.serviceWith[LiveContext](_.staticChanged)

View 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

View 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)

View file

@ -0,0 +1 @@
console.log('test-asset');

View file

@ -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)
}
)