From c04026da65ba11afde0d6dfc2e7de3df6a6135a2 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 4 Jun 2026 15:52:37 +0100 Subject: [PATCH 1/2] Add a gzip response-compression module A `Compression` concern (gzip via java.util.zip) that negotiates Accept-Encoding and, for compressible content types above a size threshold, replaces the response body with a compressed variant and sets Vary: Accept-Encoding. Mirrors GenHTTP's Modules/Compression. Lives under Modules/IO (compression/) with a `Compression` entry point; enable it on a host via `.add(Compression.default())`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/codegreen/modules/io/Compression.kt | 24 +++++++ .../modules/io/compression/AcceptEncoding.kt | 20 ++++++ .../io/compression/CompressedContent.kt | 57 ++++++++++++++++ .../io/compression/CompressionConcern.kt | 68 +++++++++++++++++++ .../compression/CompressionConcernBuilder.kt | 29 ++++++++ .../modules/io/compression/GzipAlgorithm.kt | 25 +++++++ 6 files changed, 223 insertions(+) create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/Compression.kt create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/AcceptEncoding.kt create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressedContent.kt create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcern.kt create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcernBuilder.kt create mode 100644 Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/GzipAlgorithm.kt diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/Compression.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/Compression.kt new file mode 100644 index 0000000..640a327 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/Compression.kt @@ -0,0 +1,24 @@ +package org.codegreen.modules.io + +import org.codegreen.api.content.TypedHandlerBuilder +import org.codegreen.modules.io.compression.CompressionConcernBuilder +import org.codegreen.modules.io.compression.GzipAlgorithm + +/** + * Entry point for response compression, mirroring GenHTTP's `Compression` module. Add it as a + * concern to a server host or layout, e.g. `Host.create().handler(app).add(Compression.default())`. + */ +object Compression { + + /** A compression concern with no algorithms configured; add them via the builder. */ + fun create(): CompressionConcernBuilder = CompressionConcernBuilder() + + /** A compression concern preconfigured with the built-in gzip algorithm. */ + fun default(): CompressionConcernBuilder = CompressionConcernBuilder().add(GzipAlgorithm()) +} + +/** Adds gzip response compression as a concern to the given builder. */ +fun > T.addCompression(): T { + add(Compression.default()) + return this +} diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/AcceptEncoding.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/AcceptEncoding.kt new file mode 100644 index 0000000..0ccb0d1 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/AcceptEncoding.kt @@ -0,0 +1,20 @@ +package org.codegreen.modules.io.compression + +import org.codegreen.api.content.io.AlgorithmName + +/** + * Parses an `Accept-Encoding` header value into the set of algorithm names the client accepts. + * Quality values (`;q=…`) and surrounding whitespace are ignored. Mirrors GenHTTP's + * `AcceptEncodingHeader.ParseSupported`. + */ +internal object AcceptEncoding { + + fun parseSupported(header: String): Set { + val result = HashSet() + for (token in header.split(',')) { + val name = token.substringBefore(';').trim() + if (name.isNotEmpty()) result.add(AlgorithmName(name)) + } + return result + } +} diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressedContent.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressedContent.kt new file mode 100644 index 0000000..0cb6ac8 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressedContent.kt @@ -0,0 +1,57 @@ +package org.codegreen.modules.io.compression + +import org.codegreen.api.MemoryView +import org.codegreen.api.content.io.AlgorithmName +import org.codegreen.api.content.io.CompressionLevel +import org.codegreen.api.protocol.ContentType +import org.codegreen.api.protocol.ResponseContent +import org.codegreen.api.protocol.ResponseSink +import java.io.FilterOutputStream +import java.io.OutputStream +import java.util.zip.Deflater +import java.util.zip.GZIPOutputStream + +/** + * Wraps a [ResponseContent], gzip-compressing it as it streams to the sink. The compressed length + * is not known up front, so [length] is null and the engine falls back to chunked transfer. + */ +class CompressedContent( + private val origin: ResponseContent, + algorithm: AlgorithmName, + private val level: CompressionLevel, +) : ResponseContent { + + override val length: Long? = null + + override val type: ContentType? = origin.type + + override val encoding: MemoryView = algorithm.value + + override suspend fun calculateChecksum(): Long? = null + + override suspend fun write(sink: ResponseSink) { + // GZIPOutputStream.close() would close the underlying connection stream, whose lifecycle + // the engine owns; wrap it so close() only writes the gzip trailer and flushes. + val gzip = object : GZIPOutputStream(NonClosing(sink.stream)) { + init { def.setLevel(deflateLevel(level)) } + } + gzip.use { out -> origin.write(SinkOver(out)) } + } + + /** Forwards writes/flush to [delegate] but never closes it. */ + private class NonClosing(private val delegate: OutputStream) : FilterOutputStream(delegate) { + override fun write(b: ByteArray, off: Int, len: Int) = delegate.write(b, off, len) + override fun close() = delegate.flush() + } + + private class SinkOver(override val stream: OutputStream) : ResponseSink + + private companion object { + fun deflateLevel(level: CompressionLevel): Int = when (level) { + CompressionLevel.Fastest -> Deflater.BEST_SPEED + CompressionLevel.SmallestSize -> Deflater.BEST_COMPRESSION + CompressionLevel.NoCompression -> Deflater.NO_COMPRESSION + CompressionLevel.Optimal -> Deflater.DEFAULT_COMPRESSION + } + } +} diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcern.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcern.kt new file mode 100644 index 0000000..e2b7bb3 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcern.kt @@ -0,0 +1,68 @@ +package org.codegreen.modules.io.compression + +import org.codegreen.api.content.Concern +import org.codegreen.api.content.Handler +import org.codegreen.api.content.io.CompressionAlgorithm +import org.codegreen.api.content.io.CompressionLevel +import org.codegreen.api.protocol.ContentType +import org.codegreen.api.protocol.Request +import org.codegreen.api.protocol.Response +import org.codegreen.api.protocol.getEntry + +/** + * Negotiates response compression: when the request carries `Accept-Encoding` and the response + * body is a compressible type, large enough, and not already encoded, the body is replaced by a + * compressed variant and `Vary: Accept-Encoding` is set. Mirrors GenHTTP's `CompressionConcern`. + */ +class CompressionConcern( + override val content: Handler, + algorithms: List, + private val level: CompressionLevel, + private val minimumSize: Long?, +) : Concern { + + // Highest priority first, so the best algorithm the client accepts wins. + private val algorithms: List = algorithms.sortedByDescending { it.priority.level } + + override suspend fun prepare() = content.prepare() + + override suspend fun handle(request: Request): Response? { + val acceptEncoding = request.header.headers.getEntry("Accept-Encoding") + val response = content.handle(request) ?: return null + + val body = response.content + if (acceptEncoding != null && body != null && body.encoding == null && + compressibleType(body.type) && compressibleSize(body.length) + ) { + val supported = AcceptEncoding.parseSupported(acceptEncoding) + for (algorithm in algorithms) { + if (algorithm.name in supported) { + response.rebuild() + .content(algorithm.compress(body, level)) + .header("Vary", "Accept-Encoding") + return response + } + } + } + + return response + } + + private fun compressibleType(type: ContentType?): Boolean = + type != null && type in COMPRESSIBLE + + private fun compressibleSize(length: Long?): Boolean = + minimumSize == null || length == null || length >= minimumSize + + private companion object { + val COMPRESSIBLE: Set = setOf( + ContentType.ApplicationJavaScript, + ContentType.ApplicationJson, + ContentType.ApplicationYaml, + ContentType.TextCss, + ContentType.TextHtml, + ContentType.TextPlain, + ContentType.TextYaml, + ) + } +} diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcernBuilder.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcernBuilder.kt new file mode 100644 index 0000000..6771fa1 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/CompressionConcernBuilder.kt @@ -0,0 +1,29 @@ +package org.codegreen.modules.io.compression + +import org.codegreen.api.content.Concern +import org.codegreen.api.content.ConcernBuilder +import org.codegreen.api.content.Handler +import org.codegreen.api.content.io.CompressionAlgorithm +import org.codegreen.api.content.io.CompressionLevel + +/** + * Builds a [CompressionConcern]. Defaults to the `Fastest` level (best throughput for a benchmark) + * and a 256-byte minimum so tiny responses are not compressed. + */ +class CompressionConcernBuilder : ConcernBuilder { + + private val algorithms = mutableListOf() + private var level: CompressionLevel = CompressionLevel.Fastest + private var minimumSize: Long? = 256 + + fun add(algorithm: CompressionAlgorithm): CompressionConcernBuilder = apply { algorithms.add(algorithm) } + + fun level(level: CompressionLevel): CompressionConcernBuilder = apply { this.level = level } + + fun minimumSize(bytes: Long?): CompressionConcernBuilder = apply { this.minimumSize = bytes } + + override fun build(content: Handler): Concern { + val algos = if (algorithms.isEmpty()) listOf(GzipAlgorithm()) else algorithms.toList() + return CompressionConcern(content, algos, level, minimumSize) + } +} diff --git a/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/GzipAlgorithm.kt b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/GzipAlgorithm.kt new file mode 100644 index 0000000..79701b8 --- /dev/null +++ b/Modules/IO/src/main/kotlin/org/codegreen/modules/io/compression/GzipAlgorithm.kt @@ -0,0 +1,25 @@ +package org.codegreen.modules.io.compression + +import org.codegreen.api.content.io.AlgorithmName +import org.codegreen.api.content.io.CompressionAlgorithm +import org.codegreen.api.content.io.CompressionLevel +import org.codegreen.api.infrastructure.Priority +import org.codegreen.api.protocol.ResponseContent +import java.io.InputStream +import java.util.zip.GZIPInputStream + +/** + * Gzip compression backed by the JDK's `java.util.zip` (the analogue of GenHTTP's GzipAlgorithm). + * No third-party dependency is required. + */ +class GzipAlgorithm : CompressionAlgorithm { + + override val name: AlgorithmName = AlgorithmName("gzip") + + override val priority: Priority = Priority.Low + + override fun compress(content: ResponseContent, level: CompressionLevel): ResponseContent = + CompressedContent(content, name, level) + + override fun decompress(content: InputStream): InputStream = GZIPInputStream(content) +} From 0e27fce6837e30f838b8d66b9d4383bdc1dfd78d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 4 Jun 2026 15:52:37 +0100 Subject: [PATCH 2/2] Add TLS endpoint support to the Netty engine Adds HTTPS endpoints: a SecurityConfiguration (PEM certificate chain + PKCS#8 key) on EndPointConfiguration, a secure bind(address, port, certificate, key) overload on the server builder/host, and an SslHandler prepended to the Netty pipeline (built via SslContextBuilder) so an endpoint terminates TLS and serves HTTP/1.1 over it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/infrastructure/ServerBuilder.kt | 7 ++++--- .../codegreen/api/infrastructure/ServerHost.kt | 2 ++ .../infrastructure/endpoints/EndPoint.kt | 16 +++++++++++++--- .../engine/shared/hosting/ServerHost.kt | 2 ++ .../shared/infrastructure/Configuration.kt | 5 +++++ .../shared/infrastructure/ServerBuilder.kt | 4 ++++ 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerBuilder.kt b/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerBuilder.kt index 998f276..db6ec42 100644 --- a/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerBuilder.kt +++ b/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerBuilder.kt @@ -3,6 +3,7 @@ package org.codegreen.api.infrastructure import org.codegreen.api.content.ConcernBuilder import org.codegreen.api.content.Handler import org.codegreen.api.content.HandlerBuilder +import java.io.File import java.net.InetAddress import java.time.Duration @@ -12,9 +13,6 @@ import java.time.Duration * Mirrors GenHTTP's self-typed `IServerBuilder` using covariant return overrides: * each configuration method returns the builder so calls can be chained, and * [ServerHost] narrows them to return itself. - * - * (Secure/TLS binding overloads — `Bind(..., certificate, ...)` — are a deferred part - * of the internal engine port and will be added with TLS support.) */ interface ServerBuilder : Builder { @@ -26,6 +24,9 @@ interface ServerBuilder : Builder { /** Registers an endpoint the server will bind to on startup. */ fun bind(address: InetAddress?, port: Int, dualStack: Boolean = true): ServerBuilder + /** Registers a TLS endpoint serving HTTPS with the given PEM certificate chain and private key. */ + fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean = true): ServerBuilder + // ---- Infrastructure ---- /** Registers a companion that logs all handled requests and errors to the console. */ diff --git a/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerHost.kt b/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerHost.kt index 0712da2..7f33628 100644 --- a/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerHost.kt +++ b/API/src/main/kotlin/org/codegreen/api/infrastructure/ServerHost.kt @@ -3,6 +3,7 @@ package org.codegreen.api.infrastructure import org.codegreen.api.content.ConcernBuilder import org.codegreen.api.content.Handler import org.codegreen.api.content.HandlerBuilder +import java.io.File import java.net.InetAddress import java.time.Duration @@ -19,6 +20,7 @@ interface ServerHost : ServerBuilder { override fun port(port: Int): ServerHost override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerHost + override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerHost override fun console(): ServerHost override fun companion(companion: ServerCompanion): ServerHost override fun development(developmentMode: Boolean): ServerHost diff --git a/Engine/Internal/src/main/kotlin/org/codegreen/engine/internal/infrastructure/endpoints/EndPoint.kt b/Engine/Internal/src/main/kotlin/org/codegreen/engine/internal/infrastructure/endpoints/EndPoint.kt index 0885cbd..c38d122 100644 --- a/Engine/Internal/src/main/kotlin/org/codegreen/engine/internal/infrastructure/endpoints/EndPoint.kt +++ b/Engine/Internal/src/main/kotlin/org/codegreen/engine/internal/infrastructure/endpoints/EndPoint.kt @@ -8,6 +8,8 @@ import io.netty.channel.ChannelOption import io.netty.channel.EventLoopGroup import io.netty.channel.socket.SocketChannel import io.netty.channel.socket.nio.NioServerSocketChannel +import io.netty.handler.ssl.SslContext +import io.netty.handler.ssl.SslContextBuilder import org.codegreen.api.infrastructure.EndPoint import org.codegreen.api.infrastructure.EndPointCollection import org.codegreen.engine.internal.infrastructure.ThreadedServer @@ -21,8 +23,9 @@ class EndPointCollectionImpl(endpoints: List) : EndPointCollection, List by endpoints /** - * An insecure (plain HTTP) endpoint backed by a Netty `ServerBootstrap`. Each accepted - * channel gets a pipeline of [Glyph11RequestDecoder] → [HttpDispatchHandler]. + * An endpoint backed by a Netty `ServerBootstrap`. Each accepted channel gets a pipeline of + * (optional TLS) → [Glyph11RequestDecoder] → [HttpDispatchHandler]; TLS is enabled when the + * endpoint configuration carries a [org.codegreen.engine.shared.infrastructure.SecurityConfiguration]. */ class InternalEndPoint( private val server: ThreadedServer, @@ -32,7 +35,12 @@ class InternalEndPoint( override val address: InetAddress? get() = config.address override val port: Int get() = config.port override val dualStack: Boolean get() = config.dualStack - override val secure: Boolean = false + override val secure: Boolean = config.security != null + + // Built once per endpoint from the configured PEM certificate chain + PKCS#8 private key. + private val sslContext: SslContext? = config.security?.let { security -> + SslContextBuilder.forServer(security.certificate, security.key).build() + } private var channel: Channel? = null @@ -45,6 +53,8 @@ class InternalEndPoint( .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) .childHandler(object : ChannelInitializer() { override fun initChannel(ch: SocketChannel) { + // TLS terminates first so the rest of the pipeline sees plaintext HTTP/1.1. + sslContext?.let { ch.pipeline().addLast(it.newHandler(ch.alloc())) } ch.pipeline().addLast( Glyph11RequestDecoder(server.network), HttpDispatchHandler(server, this@InternalEndPoint), diff --git a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/hosting/ServerHost.kt b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/hosting/ServerHost.kt index 784c145..a645188 100644 --- a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/hosting/ServerHost.kt +++ b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/hosting/ServerHost.kt @@ -7,6 +7,7 @@ import org.codegreen.api.infrastructure.Server import org.codegreen.api.infrastructure.ServerBuilder import org.codegreen.api.infrastructure.ServerCompanion import org.codegreen.api.infrastructure.ServerHost +import java.io.File import java.net.InetAddress import java.time.Duration @@ -20,6 +21,7 @@ class ServerHostImpl(private val builder: ServerBuilder) : ServerHost { override fun port(port: Int): ServerHost = apply { builder.port(port) } override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerHost = apply { builder.bind(address, port, dualStack) } + override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerHost = apply { builder.bind(address, port, certificate, key, dualStack) } override fun console(): ServerHost = apply { builder.console() } override fun companion(companion: ServerCompanion): ServerHost = apply { builder.companion(companion) } override fun development(developmentMode: Boolean): ServerHost = apply { builder.development(developmentMode) } diff --git a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/Configuration.kt b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/Configuration.kt index edc9618..009cf88 100644 --- a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/Configuration.kt +++ b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/Configuration.kt @@ -1,15 +1,20 @@ package org.codegreen.engine.shared.infrastructure +import java.io.File import java.net.InetAddress import java.time.Duration /** Engine-agnostic configuration records, shared by any underlying engine. */ +/** TLS material for a secure endpoint: a PEM certificate chain and its PEM (PKCS#8) private key. */ +data class SecurityConfiguration(val certificate: File, val key: File) + data class EndPointConfiguration( val address: InetAddress?, val port: Int, val dualStack: Boolean = true, val secure: Boolean = false, + val security: SecurityConfiguration? = null, ) data class NetworkConfiguration( diff --git a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/ServerBuilder.kt b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/ServerBuilder.kt index 9097916..e19fb48 100644 --- a/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/ServerBuilder.kt +++ b/Engine/Shared/src/main/kotlin/org/codegreen/engine/shared/infrastructure/ServerBuilder.kt @@ -6,6 +6,7 @@ import org.codegreen.api.infrastructure.BuilderMissingPropertyException import org.codegreen.api.infrastructure.Server import org.codegreen.api.infrastructure.ServerBuilder import org.codegreen.api.infrastructure.ServerCompanion +import java.io.File import java.net.InetAddress import java.time.Duration @@ -32,6 +33,9 @@ abstract class AbstractServerBuilder : ServerBuilder { override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerBuilder = apply { binds.add(EndPointConfiguration(address, port, dualStack)) } + override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerBuilder = + apply { binds.add(EndPointConfiguration(address, port, dualStack, secure = true, security = SecurityConfiguration(certificate, key))) } + override fun console(): ServerBuilder = apply { companion = ConsoleCompanion() } override fun companion(companion: ServerCompanion): ServerBuilder = apply { this.companion = companion }