Add minimal HTTP server and shared network type packages
This commit is contained in:
1 parent
79b015ee56
commit
01ceecd7df
28 files changed
+2642
-55
No files matched your search
@@ -0,0 +1,600 @@
|
||||
# Proposal: Minimal HTTP/1.1 + WebSocket Server For `lyngio`
|
||||
|
||||
Status: Draft
|
||||
Date: 2026-04-26
|
||||
Owner: `lyngio`
|
||||
|
||||
## Context
|
||||
|
||||
`lyngio` already provides:
|
||||
|
||||
- HTTP client support via `lyng.io.http`
|
||||
- WebSocket client support via `lyng.io.ws`
|
||||
- raw TCP/UDP transport via `lyng.io.net`
|
||||
|
||||
The current transport layer is already multiplatform and exposes a small common Kotlin interface:
|
||||
|
||||
- `LyngTcpSocket`
|
||||
- `LyngTcpServer`
|
||||
- `LyngNetEngine`
|
||||
|
||||
This makes it practical to add a minimal server implementation in pure Kotlin without introducing a second public networking model.
|
||||
|
||||
The intended deployment model for this server is:
|
||||
|
||||
- behind a frontend proxy such as nginx
|
||||
- no TLS termination in `lyngio`
|
||||
- no HTTP/2 in `lyngio` v1
|
||||
- minimal, strict HTTP/1.1 subset
|
||||
- classic HTTP/1.1 WebSocket upgrade support
|
||||
|
||||
This proposal deliberately does **not** attempt to implement HTTP/2. That work is substantially larger because it requires binary framing, stream multiplexing, HPACK, and flow control. For the intended deployment model, a frontend proxy can provide TLS and public HTTP/2 while `lyngio` speaks HTTP/1.1 on the backend.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a minimal HTTP server implementation in pure Kotlin.
|
||||
- Keep the implementation compatible with Kotlin Multiplatform common code constraints.
|
||||
- Reuse the existing `lyngio.net` TCP transport layer.
|
||||
- Support a strict, useful HTTP/1.1 subset.
|
||||
- Support classic WebSocket upgrade from HTTP/1.1.
|
||||
- Keep the API and implementation small enough to be auditable and testable.
|
||||
- Preserve room for later richer server APIs or JVM-specific backends.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- HTTP/2
|
||||
- TLS
|
||||
- ALPN
|
||||
- proxy protocol support
|
||||
- request pipelining
|
||||
- chunked request bodies
|
||||
- HTTP trailers
|
||||
- content compression
|
||||
- multipart/form-data parsing
|
||||
- range requests
|
||||
- streaming request bodies in v1
|
||||
- streaming response bodies in v1
|
||||
- WebSocket extensions
|
||||
- WebSocket subprotocol negotiation in v1
|
||||
- exposing Ktor server APIs or types
|
||||
|
||||
## Design principles
|
||||
|
||||
### 1. Common-code first
|
||||
|
||||
The implementation should live primarily in `commonMain` and depend only on existing common abstractions built on top of `LyngTcpSocket` and `LyngTcpServer`.
|
||||
|
||||
### 2. Strict subset over broad tolerance
|
||||
|
||||
The server should reject unsupported or ambiguous protocol constructs instead of trying to be maximally permissive.
|
||||
|
||||
This reduces complexity, avoids parser edge cases, and makes connection reuse easier to reason about.
|
||||
|
||||
### 3. Small surface area
|
||||
|
||||
The first version should only implement what is needed for:
|
||||
|
||||
- ordinary backend HTTP request/response handling behind a proxy
|
||||
- WebSocket upgrade and session handling
|
||||
- persistent HTTP/1.1 connections when message framing is unambiguous
|
||||
|
||||
### 4. Frontend proxy assumption
|
||||
|
||||
The server is expected to run behind nginx or a similar reverse proxy that can provide:
|
||||
|
||||
- TLS termination
|
||||
- public HTTP/2 if needed
|
||||
- request filtering and size limiting
|
||||
- buffering and slow-client protection
|
||||
- optional compression and edge-specific behavior
|
||||
|
||||
## Proposed package
|
||||
|
||||
Add a new internal package:
|
||||
|
||||
- `net.sergeych.lyngio.http.server`
|
||||
|
||||
This proposal defines an internal Kotlin API first. Lyng-facing scripting bindings are explicitly out of scope for the first phase.
|
||||
|
||||
## Supported HTTP request subset
|
||||
|
||||
### Request line
|
||||
|
||||
Accepted format:
|
||||
|
||||
- `METHOD SP request-target SP HTTP/1.1`
|
||||
|
||||
Rules:
|
||||
|
||||
- request line must split into exactly 3 parts
|
||||
- `METHOD` must be a non-empty HTTP token
|
||||
- version must be exactly `HTTP/1.1`
|
||||
- request target must be origin-form only
|
||||
|
||||
Accepted request-target examples:
|
||||
|
||||
- `/`
|
||||
- `/hello`
|
||||
- `/hello/world?x=1&y=2`
|
||||
|
||||
Rejected request-target forms:
|
||||
|
||||
- absolute-form: `http://example.com/x`
|
||||
- authority-form
|
||||
- asterisk-form: `*`
|
||||
|
||||
### Methods
|
||||
|
||||
The parser should accept any syntactically valid token as a method and expose it as a string.
|
||||
|
||||
The handler layer may then decide what to do with it.
|
||||
|
||||
This keeps the parser generic and avoids hardcoding a small method list.
|
||||
|
||||
### Headers
|
||||
|
||||
Rules:
|
||||
|
||||
- header section ends at the first empty line
|
||||
- each header line must have `name:value` form
|
||||
- header names are case-insensitive for lookup
|
||||
- original header values are preserved
|
||||
- repeated headers are preserved as repeated values
|
||||
- obsolete line folding is rejected
|
||||
- embedded CR or LF in header values is rejected
|
||||
|
||||
### Host header
|
||||
|
||||
Rules:
|
||||
|
||||
- `Host` is required on every request
|
||||
- there must be exactly one effective host value after normalization
|
||||
- duplicate `Host` values are allowed only if they are identical after trimming
|
||||
- conflicting `Host` values are rejected
|
||||
|
||||
### Request bodies
|
||||
|
||||
v1 accepted request body framing:
|
||||
|
||||
- no body
|
||||
- body with a valid `Content-Length`
|
||||
|
||||
v1 rejected request body framing:
|
||||
|
||||
- any `Transfer-Encoding`
|
||||
- chunked request bodies
|
||||
- ambiguous or conflicting body framing
|
||||
|
||||
### Keep-alive
|
||||
|
||||
HTTP/1.1 persistent connections are supported.
|
||||
|
||||
Rules:
|
||||
|
||||
- keep-alive is the default
|
||||
- the server closes the connection if the client sends `Connection: close`
|
||||
- the server may close the connection after any response if it chooses
|
||||
- the server closes the connection on parse errors or framing errors
|
||||
- after a successful WebSocket upgrade, the HTTP request loop ends for that socket
|
||||
|
||||
### WebSocket upgrade
|
||||
|
||||
v1 supports classic HTTP/1.1 upgrade to WebSocket.
|
||||
|
||||
Required request properties:
|
||||
|
||||
- method is `GET`
|
||||
- `Upgrade: websocket`
|
||||
- `Connection` contains token `upgrade`
|
||||
- `Sec-WebSocket-Key` is present
|
||||
- `Sec-WebSocket-Version: 13`
|
||||
|
||||
v1 behavior:
|
||||
|
||||
- no subprotocol negotiation
|
||||
- no extension negotiation
|
||||
- no HTTP/2 WebSocket support
|
||||
- no fallback upgrade modes beyond the standard HTTP/1.1 handshake
|
||||
|
||||
## Rejection and error rules
|
||||
|
||||
### `400 Bad Request`
|
||||
|
||||
Return `400` for:
|
||||
|
||||
- malformed request line
|
||||
- invalid HTTP token in method or header name
|
||||
- unsupported request-target form
|
||||
- missing `Host`
|
||||
- conflicting `Host` values
|
||||
- invalid header syntax
|
||||
- obsolete folded headers
|
||||
- invalid `Content-Length`
|
||||
- conflicting duplicate `Content-Length`
|
||||
- invalid WebSocket upgrade request
|
||||
|
||||
### `413 Payload Too Large`
|
||||
|
||||
Return `413` when request body exceeds configured maximum size.
|
||||
|
||||
### `414 URI Too Long`
|
||||
|
||||
Return `414` when the request-target exceeds configured limits.
|
||||
|
||||
### `431 Request Header Fields Too Large`
|
||||
|
||||
Return `431` when:
|
||||
|
||||
- total header bytes exceed the configured limit
|
||||
- header count exceeds the configured limit
|
||||
- an individual header line exceeds the configured limit if such a per-line limit is introduced
|
||||
|
||||
### `501 Not Implemented`
|
||||
|
||||
Return `501` for:
|
||||
|
||||
- `Transfer-Encoding` in requests
|
||||
- chunked request bodies
|
||||
- `Expect: 100-continue`
|
||||
- unsupported `Upgrade` values
|
||||
- request features intentionally excluded from v1
|
||||
|
||||
### `505 HTTP Version Not Supported`
|
||||
|
||||
Return `505` for any HTTP version other than `HTTP/1.1`.
|
||||
|
||||
### `500 Internal Server Error`
|
||||
|
||||
Return `500` when the request was parsed successfully but the application handler throws or otherwise fails unexpectedly.
|
||||
|
||||
## Response model
|
||||
|
||||
v1 responses should be fully materialized before writing.
|
||||
|
||||
Rules:
|
||||
|
||||
- always send a status line
|
||||
- always send response headers
|
||||
- prefer sending `Content-Length` on all normal responses
|
||||
- do not emit chunked responses in v1
|
||||
- if response framing is ambiguous, close the connection instead of attempting reuse
|
||||
|
||||
Connection closing rules:
|
||||
|
||||
- include `Connection: close` when the server intends to close after the response
|
||||
- close after the response if the request asked for `Connection: close`
|
||||
- close after protocol errors
|
||||
- after `101 Switching Protocols`, the HTTP server loop yields ownership of the socket to the WebSocket session
|
||||
|
||||
## Suggested defaults and limits
|
||||
|
||||
Default operational limits:
|
||||
|
||||
- maximum request line bytes: `8 KiB`
|
||||
- maximum total header bytes: `32 KiB`
|
||||
- maximum header count: `100`
|
||||
- maximum request body bytes: `1 MiB`
|
||||
- keep-alive idle timeout: `15_000 ms`
|
||||
|
||||
These should be configurable per server instance.
|
||||
|
||||
## Internal Kotlin API
|
||||
|
||||
The following shape is recommended as the initial internal API.
|
||||
|
||||
```kotlin
|
||||
data class HttpServerConfig(
|
||||
val host: String? = "127.0.0.1",
|
||||
val port: Int = 0,
|
||||
val backlog: Int = 128,
|
||||
val reuseAddress: Boolean = true,
|
||||
val maxRequestLineBytes: Int = 8 * 1024,
|
||||
val maxHeaderBytes: Int = 32 * 1024,
|
||||
val maxHeaderCount: Int = 100,
|
||||
val maxBodyBytes: Int = 1 * 1024 * 1024,
|
||||
val keepAliveTimeoutMillis: Long = 15_000,
|
||||
)
|
||||
|
||||
data class HttpHeader(
|
||||
val name: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
class HttpHeaders(
|
||||
private val entries: List<HttpHeader>,
|
||||
) {
|
||||
fun first(name: String): String?
|
||||
fun all(name: String): List<String>
|
||||
fun containsToken(name: String, token: String): Boolean
|
||||
fun entries(): List<HttpHeader>
|
||||
}
|
||||
|
||||
data class HttpRequestHead(
|
||||
val method: String,
|
||||
val target: String,
|
||||
val path: String,
|
||||
val query: String?,
|
||||
val version: String,
|
||||
val headers: HttpHeaders,
|
||||
val contentLength: Int?,
|
||||
val wantsClose: Boolean,
|
||||
val wantsWebSocketUpgrade: Boolean,
|
||||
)
|
||||
|
||||
data class HttpRequest(
|
||||
val head: HttpRequestHead,
|
||||
val body: ByteArray,
|
||||
)
|
||||
|
||||
data class HttpResponse(
|
||||
val status: Int,
|
||||
val reason: String = defaultReason(status),
|
||||
val headers: List<HttpHeader> = emptyList(),
|
||||
val body: ByteArray = ByteArray(0),
|
||||
val close: Boolean = false,
|
||||
)
|
||||
|
||||
interface HttpWebSocketSession {
|
||||
fun isOpen(): Boolean
|
||||
suspend fun sendText(text: String)
|
||||
suspend fun sendBytes(data: ByteArray)
|
||||
suspend fun receive(): net.sergeych.lyngio.ws.LyngWsMessage?
|
||||
suspend fun close(code: Int = 1000, reason: String = "")
|
||||
}
|
||||
|
||||
sealed interface HttpHandlerResult {
|
||||
data class Response(val response: HttpResponse) : HttpHandlerResult
|
||||
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
|
||||
}
|
||||
|
||||
fun interface HttpHandler {
|
||||
suspend fun handle(request: HttpRequest): HttpHandlerResult
|
||||
}
|
||||
|
||||
interface HttpServer {
|
||||
fun isOpen(): Boolean
|
||||
fun localAddress(): net.sergeych.lyngio.net.LyngSocketAddress
|
||||
fun close()
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation architecture
|
||||
|
||||
The implementation should be split into a small number of focused components.
|
||||
|
||||
### 1. `HttpServer.kt`
|
||||
|
||||
Contains:
|
||||
|
||||
- public internal interfaces and data classes
|
||||
- config and response models
|
||||
- default reason phrase mapping
|
||||
|
||||
### 2. `BufferedSocketReader.kt`
|
||||
|
||||
A small internal reader built on top of `LyngTcpSocket`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- buffered reads
|
||||
- line reads with explicit limits
|
||||
- exact byte reads for request bodies and WebSocket frames
|
||||
- avoiding fragile mixing of raw `read()` and `readLine()` semantics
|
||||
|
||||
This reader should be internal and should not require changes to `LyngTcpSocket` in v1.
|
||||
|
||||
### 3. `HttpParser.kt`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- request line parsing
|
||||
- target parsing into `path` and optional query
|
||||
- header parsing and normalization
|
||||
- validation of `Host`, `Content-Length`, and connection semantics
|
||||
- mapping parse failures into typed HTTP errors
|
||||
|
||||
### 4. `HttpWriter.kt`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- writing status line and headers
|
||||
- adding `Content-Length` where needed
|
||||
- setting `Connection: close` when the server intends to close
|
||||
- writing the response body
|
||||
- flushing output
|
||||
|
||||
### 5. `HttpServerLoop.kt`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- accept loop over `LyngTcpServer`
|
||||
- per-connection request loop
|
||||
- keep-alive timeout handling
|
||||
- error-to-response mapping
|
||||
- handing off upgraded sockets to WebSocket session implementation
|
||||
|
||||
### 6. `ServerWebSocket.kt`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- validating upgrade request
|
||||
- computing `Sec-WebSocket-Accept`
|
||||
- writing `101 Switching Protocols`
|
||||
- reading and writing WebSocket frames
|
||||
- close handling
|
||||
|
||||
This should reuse the client-side frame and handshake logic already present in spirit, but server-side behavior should stay separate and explicit.
|
||||
|
||||
## Connection processing model
|
||||
|
||||
Per accepted TCP connection:
|
||||
|
||||
1. read request line
|
||||
2. read headers
|
||||
3. validate request
|
||||
4. read request body if `Content-Length` is present
|
||||
5. call the application handler
|
||||
6. if handler returns HTTP response, write it and decide whether to continue
|
||||
7. if handler returns WebSocket upgrade, send `101`, create a WebSocket session, and transfer ownership of the socket
|
||||
8. continue until close, error, timeout, or upgrade
|
||||
|
||||
The server should process one request at a time per connection.
|
||||
|
||||
Pipelining is out of scope.
|
||||
|
||||
## Detailed parser rules
|
||||
|
||||
### Method parsing
|
||||
|
||||
- method must be a valid HTTP token
|
||||
- parser does not enforce a fixed method allowlist
|
||||
|
||||
### Target parsing
|
||||
|
||||
- target must begin with `/`
|
||||
- split on the first `?`
|
||||
- `path` is the portion before `?`
|
||||
- `query` is the portion after `?`, or `null`
|
||||
- no URL decoding is required in v1; raw target text may be exposed
|
||||
|
||||
### Header parsing
|
||||
|
||||
- split each header line on the first `:`
|
||||
- trim outer spaces and tabs from the value
|
||||
- reject control characters other than horizontal tab if any are allowed at all
|
||||
- do case-insensitive matching by normalized header name
|
||||
- preserve the original values as supplied
|
||||
|
||||
### Content-Length rules
|
||||
|
||||
- absent means no request body
|
||||
- one valid decimal value is accepted
|
||||
- multiple values are accepted only if all normalized values are identical
|
||||
- negative values are rejected
|
||||
- values above configured maximum body size are rejected with `413`
|
||||
|
||||
### Connection token parsing
|
||||
|
||||
- `Connection` is tokenized case-insensitively on commas
|
||||
- surrounding spaces are ignored
|
||||
- helper methods should support `containsToken("Connection", "close")`
|
||||
- helper methods should support `containsToken("Connection", "upgrade")`
|
||||
|
||||
## WebSocket v1 rules
|
||||
|
||||
### Upgrade acceptance
|
||||
|
||||
Accept only if all of the following are true:
|
||||
|
||||
- request method is `GET`
|
||||
- request version is `HTTP/1.1`
|
||||
- request body is empty
|
||||
- `Upgrade` contains `websocket`
|
||||
- `Connection` contains `upgrade`
|
||||
- `Sec-WebSocket-Key` is present and syntactically valid
|
||||
- `Sec-WebSocket-Version` equals `13`
|
||||
|
||||
Otherwise return a regular HTTP error response.
|
||||
|
||||
### WebSocket features in v1
|
||||
|
||||
Supported:
|
||||
|
||||
- text messages
|
||||
- binary messages
|
||||
- ping/pong handling
|
||||
- close handshake
|
||||
|
||||
Not supported in v1:
|
||||
|
||||
- permessage-deflate
|
||||
- subprotocol negotiation
|
||||
- fragmented-message streaming to the application
|
||||
- very large frame optimizations beyond a reasonable implementation limit
|
||||
|
||||
## Testing plan
|
||||
|
||||
A server like this should be tested at three levels.
|
||||
|
||||
### 1. Parser unit tests
|
||||
|
||||
Cases:
|
||||
|
||||
- valid request line parsing
|
||||
- invalid request line parsing
|
||||
- target parsing with and without query
|
||||
- header case-insensitive lookup
|
||||
- duplicate `Host` handling
|
||||
- duplicate `Content-Length` handling
|
||||
- oversized request line rejection
|
||||
- oversized headers rejection
|
||||
- `Transfer-Encoding` rejection
|
||||
|
||||
### 2. Engine-level loopback tests
|
||||
|
||||
Using existing TCP backends:
|
||||
|
||||
- simple `GET` request and response
|
||||
- `POST` with `Content-Length`
|
||||
- keep-alive with two sequential requests on one socket
|
||||
- `Connection: close`
|
||||
- malformed request closes connection
|
||||
- handler exception becomes `500`
|
||||
- body too large becomes `413`
|
||||
|
||||
### 3. WebSocket upgrade tests
|
||||
|
||||
Cases:
|
||||
|
||||
- successful upgrade handshake
|
||||
- text echo
|
||||
- binary echo
|
||||
- ping/pong behavior
|
||||
- clean close handshake
|
||||
- invalid upgrade headers rejected as HTTP errors
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: internal HTTP server core
|
||||
|
||||
Implement:
|
||||
|
||||
- config
|
||||
- buffered reader
|
||||
- parser
|
||||
- writer
|
||||
- request loop
|
||||
- fixed-body responses
|
||||
- keep-alive
|
||||
|
||||
### Phase 2: server-side WebSocket upgrade
|
||||
|
||||
Implement:
|
||||
|
||||
- upgrade validation
|
||||
- `101 Switching Protocols`
|
||||
- WebSocket frame IO
|
||||
- session object
|
||||
- close and ping/pong handling
|
||||
|
||||
### Phase 3: host integration and optional Lyng exposure
|
||||
|
||||
Possible future work:
|
||||
|
||||
- host-facing convenience factory APIs
|
||||
- Lyng module exposure if there is a clear scripting use case
|
||||
- route helpers or lightweight dispatching
|
||||
- JVM-specific richer backends if requirements grow
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should the first version expose only a Kotlin host API, or should it also be surfaced to Lyng scripts immediately?
|
||||
2. Should response headers be represented as repeated `HttpHeader` entries only, or should a convenience builder API be added from the start?
|
||||
3. Should the first version include a small path router helper, or should routing stay entirely in host code?
|
||||
4. Should very small chunked response support be added later if keep-alive plus unknown response length becomes a real need, or should v1 require fully materialized responses only?
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed with this strict HTTP/1.1 + WebSocket subset.
|
||||
|
||||
It is small enough to finish in common Kotlin, fits the current `lyngio` transport architecture, and avoids turning the project into a full protocol-stack implementation effort.
|
||||
Reference in new issue
Block a user