From 5381487d4fcf550d6d6664f507e6d2ae7b88e4b6 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Tue, 23 Dec 2025 23:06:16 +0100 Subject: [PATCH] Serve hashed static resources --- build.mill | 18 +++--- example/src/Example.scala | 5 +- example/src/RootLayout.scala | 23 +++++++- scalive/core/src/scalive/LiveView.scala | 1 + .../core/src/scalive/StaticAssetHasher.scala | 56 +++++++++++++++++++ .../ServeHashedResourcesMiddleware.scala | 21 +++++++ scalive/zio/test/resources/public/app.js | 1 + .../ServeHashedResourcesMiddlewareSpec.scala | 30 ++++++++++ 8 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 scalive/core/src/scalive/StaticAssetHasher.scala create mode 100644 scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala create mode 100644 scalive/zio/test/resources/public/app.js create mode 100644 scalive/zio/test/src/scalive/ServeHashedResourcesMiddlewareSpec.scala diff --git a/build.mill b/build.mill index 027e22e..782e9be 100644 --- a/build.mill +++ b/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: diff --git a/example/src/Example.scala b/example/src/Example.scala index 8c86f80..ac07840 100644 --- a/example/src/Example.scala +++ b/example/src/Example.scala @@ -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 diff --git a/example/src/RootLayout.scala b/example/src/RootLayout.scala index bfeafc8..9f8e105 100644 --- a/example/src/RootLayout.scala +++ b/example/src/RootLayout.scala @@ -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 diff --git a/scalive/core/src/scalive/LiveView.scala b/scalive/core/src/scalive/LiveView.scala index 1ec868f..52be7e3 100644 --- a/scalive/core/src/scalive/LiveView.scala +++ b/scalive/core/src/scalive/LiveView.scala @@ -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) diff --git a/scalive/core/src/scalive/StaticAssetHasher.scala b/scalive/core/src/scalive/StaticAssetHasher.scala new file mode 100644 index 0000000..e690475 --- /dev/null +++ b/scalive/core/src/scalive/StaticAssetHasher.scala @@ -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 diff --git a/scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala b/scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala new file mode 100644 index 0000000..096c29b --- /dev/null +++ b/scalive/zio/src/scalive/ServeHashedResourcesMiddleware.scala @@ -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) diff --git a/scalive/zio/test/resources/public/app.js b/scalive/zio/test/resources/public/app.js new file mode 100644 index 0000000..d4769e9 --- /dev/null +++ b/scalive/zio/test/resources/public/app.js @@ -0,0 +1 @@ +console.log('test-asset'); diff --git a/scalive/zio/test/src/scalive/ServeHashedResourcesMiddlewareSpec.scala b/scalive/zio/test/src/scalive/ServeHashedResourcesMiddlewareSpec.scala new file mode 100644 index 0000000..1414b40 --- /dev/null +++ b/scalive/zio/test/src/scalive/ServeHashedResourcesMiddlewareSpec.scala @@ -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) + } + )