mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +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 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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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