Implement lazy asset-file compression
authorLanius Trolling <lanius@laniustrolling.dev>
Thu, 28 Mar 2024 23:02:04 +0000 (19:02 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Thu, 28 Mar 2024 23:02:04 +0000 (19:02 -0400)
build.gradle.kts
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt
src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt [new file with mode: 0644]

index b2ec0e2f9ac1b50ffae8177d56f968430901f644..488b63b8bcda816ade719ba5f99b4b7489dd29d0 100644 (file)
@@ -116,17 +116,18 @@ kotlin {
                                implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.2")
                                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.2")
                                
-                               implementation("io.ktor:ktor-server-core-jvm:2.3.8")
-                               implementation("io.ktor:ktor-server-cio-jvm:2.3.8")
+                               implementation("io.ktor:ktor-server-core-jvm:2.3.9")
+                               implementation("io.ktor:ktor-server-cio-jvm:2.3.9")
                                
-                               implementation("io.ktor:ktor-server-caching-headers:2.3.8")
-                               implementation("io.ktor:ktor-server-call-id:2.3.8")
-                               implementation("io.ktor:ktor-server-call-logging:2.3.8")
-                               implementation("io.ktor:ktor-server-conditional-headers:2.3.8")
-                               implementation("io.ktor:ktor-server-forwarded-header:2.3.8")
-                               implementation("io.ktor:ktor-server-html-builder:2.3.8")
-                               implementation("io.ktor:ktor-server-sessions-jvm:2.3.8")
-                               implementation("io.ktor:ktor-server-status-pages:2.3.8")
+                               implementation("io.ktor:ktor-server-auto-head-response:2.3.9")
+                               implementation("io.ktor:ktor-server-caching-headers:2.3.9")
+                               implementation("io.ktor:ktor-server-call-id:2.3.9")
+                               implementation("io.ktor:ktor-server-call-logging:2.3.9")
+                               implementation("io.ktor:ktor-server-conditional-headers:2.3.9")
+                               implementation("io.ktor:ktor-server-forwarded-header:2.3.9")
+                               implementation("io.ktor:ktor-server-html-builder:2.3.9")
+                               implementation("io.ktor:ktor-server-sessions-jvm:2.3.9")
+                               implementation("io.ktor:ktor-server-status-pages:2.3.9")
                                
                                implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
                                
index 1a2ec5e74e6baa7a278d397c9ddc5e4c4fa9699a..f8884340876f852dccd466ad501ef26fff4e5b2b 100644 (file)
@@ -13,6 +13,7 @@ import io.ktor.server.engine.*
 import io.ktor.server.html.*
 import io.ktor.server.http.content.*
 import io.ktor.server.plugins.*
+import io.ktor.server.plugins.autohead.*
 import io.ktor.server.plugins.cachingheaders.*
 import io.ktor.server.plugins.callid.*
 import io.ktor.server.plugins.callloging.*
@@ -48,8 +49,12 @@ fun main() {
 fun Application.factbooks() {
        application = this
        
+       install(AutoHeadResponse)
        install(IgnoreTrailingSlash)
-       install(XForwardedHeaders)
+       
+       install(XForwardedHeaders) {
+               useLastProxy()
+       }
        
        install(CachingHeaders) {
                options { call, outgoingContent ->
@@ -109,7 +114,7 @@ fun Application.factbooks() {
                        call.respondRedirect(url, permanent)
                }
                exception<AprilFoolsStaticFileRedirectException> { call, (replacement) ->
-                       call.respondFile(replacement)
+                       call.respondCompressedFile(replacement)
                }
                exception<MissingRequestParameterException> { call, _ ->
                        call.respondHtml(HttpStatusCode.BadRequest, call.error400())
@@ -144,7 +149,6 @@ fun Application.factbooks() {
                
                staticResources("/static", "static", index = null) {
                        preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
-                       enableAutoHeadResponse()
                }
                
                get("/lore/{path...}") {
@@ -155,11 +159,12 @@ fun Application.factbooks() {
                        call.respondHtml(HttpStatusCode.OK, call.loreRawArticlePage(""))
                }
                
-               staticFiles("/assets", File(Configuration.CurrentConfiguration.assetDir), index = null) {
-                       enableAutoHeadResponse()
-                       modify { file, _ ->
-                               redirectStaticFileOnApril1st(file)
-                       }
+               get("/assets/{path...}") {
+                       val assetPath = call.parameters.getAll("path")?.joinToString(separator = File.separator) ?: return@get
+                       val assetFile = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath)
+                       
+                       redirectAssetOnApril1st(assetFile)
+                       call.respondCompressedFile(assetFile)
                }
                
                get("/map") {
index b075d0d623fcca8eeade149a007e67f6f5088d67..b178ad35208e1fae03f8f4a4381a6f346885024e 100644 (file)
@@ -25,6 +25,6 @@ fun redirectFileOnApril1st(requestedFile: File): File? {
        return funnyFile.takeIf { it.exists() }
 }
 
-fun redirectStaticFileOnApril1st(requestedFile: File) {
+fun redirectAssetOnApril1st(requestedFile: File) {
        redirectFileOnApril1st(requestedFile)?.let { throw AprilFoolsStaticFileRedirectException(it) }
 }
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt
new file mode 100644 (file)
index 0000000..e933ea4
--- /dev/null
@@ -0,0 +1,80 @@
+package info.mechyrdia.lore
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FilterOutputStream
+import java.io.OutputStream
+import java.util.concurrent.ConcurrentHashMap
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.GZIPOutputStream
+
+private val gzippedCache = CompressedCache.fromCompressorFactory("gzip", ::GZIPOutputStream)
+private val deflatedCache = CompressedCache.fromCompressorFactory("deflate", ::DeflaterOutputStream)
+
+private fun getCacheByEncoding(encoding: String) = when (encoding) {
+       "gzip" -> gzippedCache
+       "deflate" -> deflatedCache
+       else -> null
+}
+
+private fun ApplicationCall.compressedCache(): CompressedCache? {
+       return request.acceptEncodingItems()
+               .mapNotNull { value -> getCacheByEncoding(value.value)?.let { it to value.quality } }
+               .maxByOrNull { it.second }
+               ?.first
+}
+
+suspend fun ApplicationCall.respondCompressedFile(file: File) {
+       val cache = compressedCache() ?: return respondFile(file)
+       response.header(HttpHeaders.ContentEncoding, cache.encoding)
+       respondBytes(cache.getCompressed(file))
+}
+
+private class CompressedCache(val encoding: String, private val compressor: (ByteArray) -> ByteArray) {
+       private val cache = ConcurrentHashMap<File, CompressedCacheEntry>()
+       
+       fun getCompressed(file: File): ByteArray {
+               val lastModified = file.lastModified()
+               return cache.compute(file) { _, prevEntry ->
+                       if (prevEntry == null || prevEntry.lastModified < lastModified)
+                               CompressedCacheEntry(lastModified, compressor(file.readBytes()))
+                       else prevEntry
+               }!!.compressedData
+       }
+       
+       companion object {
+               fun fromCompressorFactory(encoding: String, compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) = CompressedCache(encoding) { uncompressed ->
+                       ByteArrayOutputStream().also { oStream ->
+                               compressorFactory(oStream, true).use { gzip ->
+                                       gzip.write(uncompressed)
+                                       gzip.flush()
+                               }
+                       }.toByteArray()
+               }
+       }
+}
+
+private data class CompressedCacheEntry(
+       val lastModified: Long,
+       val compressedData: ByteArray,
+) {
+       override fun equals(other: Any?): Boolean {
+               if (this === other) return true
+               if (other !is CompressedCacheEntry) return false
+               
+               if (lastModified != other.lastModified) return false
+               if (!compressedData.contentEquals(other.compressedData)) return false
+               
+               return true
+       }
+       
+       override fun hashCode(): Int {
+               var result = lastModified.hashCode()
+               result = 31 * result + compressedData.contentHashCode()
+               return result
+       }
+}