Cache cacheable resources
authorLanius Trolling <lanius@laniustrolling.dev>
Fri, 24 Feb 2023 17:15:51 +0000 (12:15 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Fri, 24 Feb 2023 17:15:51 +0000 (12:15 -0500)
build.gradle.kts
src/main/kotlin/info/mechyrdia/Factbooks.kt

index f18047b5f754abeaab2e4f7baa9615841e9be74a..6630ff94edcbb250c88a829e97806892f55e1b9b 100644 (file)
@@ -1,4 +1,27 @@
+import com.nixxcode.jvmbrotli.common.BrotliLoader
+import com.nixxcode.jvmbrotli.enc.BrotliOutputStream
+import com.nixxcode.jvmbrotli.enc.Encoder
 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.security.MessageDigest
+import java.util.*
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.zip.GZIPOutputStream
+
+buildscript {
+       repositories {
+               mavenCentral()
+       }
+       
+       dependencies {
+               classpath("com.nixxcode.jvmbrotli:jvmbrotli:0.2.0")
+               
+               // why does this need to be done MANUALLY?!?!
+               classpath("com.nixxcode.jvmbrotli:jvmbrotli-win32-x86-amd64:0.2.0")
+               classpath("com.nixxcode.jvmbrotli:jvmbrotli-darwin-x86-amd64:0.2.0")
+               classpath("com.nixxcode.jvmbrotli:jvmbrotli-linux-x86-amd64:0.2.0")
+       }
+}
 
 plugins {
        java
@@ -27,6 +50,7 @@ dependencies {
        
        implementation("io.ktor:ktor-server-call-id:2.2.3")
        implementation("io.ktor:ktor-server-call-logging:2.2.3")
+       implementation("io.ktor:ktor-server-conditional-headers:2.2.3")
        implementation("io.ktor:ktor-server-forwarded-header:2.2.3")
        implementation("io.ktor:ktor-server-html-builder:2.2.3")
        implementation("io.ktor:ktor-server-sessions-jvm:2.2.3")
@@ -68,6 +92,45 @@ tasks.withType<KotlinCompile> {
        }
 }
 
+tasks.named<Copy>("processResources") {
+       doLast {
+               val pool = Executors.newWorkStealingPool()
+               
+               val encoderParams = if (BrotliLoader.isBrotliAvailable()) Encoder.Parameters().setQuality(8) else null
+               val base64 = Base64.getUrlEncoder()
+               val hashDigest = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
+               
+               val resourceTree = fileTree(mapOf("dir" to outputs.files.asPath + "/static/", "exclude" to listOf("*.gz", "*.br", "*.sha256")))
+               val countDownLatch = CountDownLatch(resourceTree.count())
+               
+               for (file in resourceTree) {
+                       pool.execute {
+                               val bytes = file.readBytes()
+                               val hashFile = File("${file.absolutePath}.sha256").bufferedWriter()
+                               hashFile.write(base64.encodeToString(hashDigest.get().digest(bytes)).trimEnd('='))
+                               hashFile.close()
+                               
+                               val result = File("${file.absolutePath}.gz").outputStream()
+                               val gzipStream = GZIPOutputStream(result)
+                               gzipStream.write(bytes)
+                               gzipStream.close()
+                               
+                               encoderParams?.let { encParams ->
+                                       val brResult = File("${file.absolutePath}.br").outputStream()
+                                       val brStream = BrotliOutputStream(brResult, encParams)
+                                       brStream.write(bytes)
+                                       brStream.close()
+                               }
+                               
+                               println("Done compressing ${file.name}")
+                               countDownLatch.countDown()
+                       }
+               }
+               
+               countDownLatch.await()
+       }
+}
+
 application {
        mainClass.set("info.mechyrdia.Factbooks")
 }
index 82ad2fd9c84c50bf603636946410b83c5d64c5db..b9157341d87b9e991e85600ffe0bffb617fbac8b 100644 (file)
@@ -6,6 +6,7 @@ import info.mechyrdia.auth.*
 import info.mechyrdia.data.*
 import info.mechyrdia.lore.*
 import io.ktor.http.*
+import io.ktor.http.content.*
 import io.ktor.server.application.*
 import io.ktor.server.engine.*
 import io.ktor.server.html.*
@@ -14,6 +15,7 @@ import io.ktor.server.netty.*
 import io.ktor.server.plugins.*
 import io.ktor.server.plugins.callid.*
 import io.ktor.server.plugins.callloging.*
+import io.ktor.server.plugins.conditionalheaders.*
 import io.ktor.server.plugins.forwardedheaders.*
 import io.ktor.server.plugins.statuspages.*
 import io.ktor.server.request.*
@@ -26,8 +28,15 @@ import io.ktor.util.*
 import org.slf4j.event.Level
 import java.io.File
 import java.io.IOException
+import java.io.InputStream
 import java.util.concurrent.atomic.AtomicLong
 
+object ResourceLoader {
+       fun getResource(resource: String): InputStream? = javaClass.getResourceAsStream(resource)
+       
+       val SHA256AttributeKey = AttributeKey<String>("SHA256Hash")
+}
+
 lateinit var application: Application
        private set
 
@@ -64,6 +73,14 @@ fun Application.factbooks() {
                }
        }
        
+       install(ConditionalHeaders) {
+               version { call, _ ->
+                       call.attributes.getOrNull(ResourceLoader.SHA256AttributeKey)?.let { hash ->
+                               listOf(EntityTagVersion(hash))
+                       }.orEmpty()
+               }
+       }
+       
        install(Sessions) {
                cookie("USER_SESSION", SessionStorageMongoDB) {
                        identity { Id<UserSession>().id }
@@ -115,7 +132,49 @@ fun Application.factbooks() {
                // Factbooks and assets
                
                static("/static") {
-                       resources("static")
+                       get("{static-content...}") {
+                               val staticContentPath = call.parameters.getAll("static-content")?.joinToString("/") ?: return@get
+                               val contentPath = "/static/$staticContentPath"
+                               
+                               ResourceLoader.getResource("$contentPath.sha256")?.reader()?.readText()?.let { sha256Hash ->
+                                       call.attributes.put(ResourceLoader.SHA256AttributeKey, sha256Hash)
+                               }
+                               
+                               val brContentPath = "$contentPath.br"
+                               val gzContentPath = "$contentPath.gz"
+                               
+                               val contentType = ContentType.fromFileExtension(contentPath.substringAfterLast('.')).firstOrNull()
+                               
+                               val acceptedEncodings = call.request.acceptEncodingItems().map { it.value }.toSet()
+                               
+                               if (CompressedFileType.BROTLI.encoding in acceptedEncodings) {
+                                       val brContent = ResourceLoader.getResource(brContentPath)
+                                       if (brContent != null) {
+                                               call.attributes.put(SuppressionAttribute, true)
+                                               
+                                               call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.BROTLI.encoding)
+                                               
+                                               call.respondBytes(brContent.readBytes(), contentType)
+                                               
+                                               return@get
+                                       }
+                               }
+                               
+                               if (CompressedFileType.GZIP.encoding in acceptedEncodings) {
+                                       val gzContent = ResourceLoader.getResource(gzContentPath)
+                                       if (gzContent != null) {
+                                               call.attributes.put(SuppressionAttribute, true)
+                                               
+                                               call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.GZIP.encoding)
+                                               
+                                               call.respondBytes(gzContent.readBytes(), contentType)
+                                               
+                                               return@get
+                                       }
+                               }
+                               
+                               ResourceLoader.getResource(contentPath)?.let { call.respondBytes(it.readBytes(), contentType) }
+                       }
                }
                
                get("/lore/{path...}") {