Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **HTTP body compression** — gzip on responses and inbound request
bodies, served identically across HTTP/1.1, HTTP/2 and HTTP/3.
Build flag: `--enable-http-compression` (default on; auto-detects
zlib-ng with system zlib as fallback).

Five `HttpServerConfig` setters drive the policy and are frozen at
`HttpServer::__construct`:
- `setCompressionEnabled(bool)` — master switch (default `true`).
- `setCompressionLevel(int)` — zlib level 1..9 (default 6).
- `setCompressionMinSize(int)` — body-size threshold below which
responses stay identity (default 1 KiB; valid 0..16 MiB).
- `setCompressionMimeTypes(array)` — replaces the whitelist
wholesale (nginx semantics). Default ships the union of nginx
`gzip_types` and h2o text-only defaults.
- `setRequestMaxDecompressedSize(int)` — anti-zip-bomb cap on
decoded request bodies (default 10 MiB; 0 = no cap, must be
explicit).

Per-response opt-out: `HttpResponse::setNoCompression()` overrides
every other rule. Use for endpoints combining secrets with
reflected user input (BREACH mitigation), pre-encoded payloads,
or anywhere the server must not wrap the body.

Negotiation follows RFC 9110 §12.5.3 — q-values, `identity;q=0`,
`*;q=0` excludes identity unless an explicit identity entry
rescues it. Default when no `Accept-Encoding` header is sent
resolves to identity-only (matches nginx; safer than the strict
RFC reading). Skip rules: status 1xx/204/304, HEAD, Range
responses, handler-set `Content-Encoding`, MIME outside the
whitelist, body below the threshold.

Inbound: `Content-Encoding: gzip` (and the legacy `x-gzip`
alias) on requests is decoded transparently. `identity` is a
no-op. Unknown codings → 415; bomb-cap exceeded → 413; corrupt
inflate → 400. The handler observes the decoded body via
`HttpRequest::getBody()`.

Streaming: when handlers call `HttpResponse::send($chunk)`, the
compressing wrapper transparently engages on first call (subject
to negotiation) and produces one downstream chunk per source
chunk — preserving framing efficiency on chunked H1 and H2
DATA frames.

Backend: `zlib-ng` is preferred at build time for ~2-4× higher
throughput at the same compression level; system `zlib` is the
drop-in fallback. Both share the same source via a thin
`zng_*` ↔ `*` macro layer.

Issue [#8](https://github.com/true-async/server/issues/8).

## [0.2.0] - 2026-05-04

### Added
Expand Down
47 changes: 47 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,52 @@ if(ENABLE_HTTP3)
endif()
endif()

# HTTP body compression (issue #8). Mirrors config.m4: prefer zlib-ng
# (faster API-compatible drop-in for zlib), fall back to system zlib.
# Fail-soft: if neither is found, HAVE_HTTP_COMPRESSION stays undefined
# and the compression sources are dropped from the build.
option(ENABLE_HTTP_COMPRESSION "Enable HTTP body compression (zlib-ng preferred, zlib fallback)" ON)
set(COMPRESSION_SOURCES "")
if(ENABLE_HTTP_COMPRESSION)
find_package(PkgConfig QUIET)
set(_compression_ok FALSE)
if(PkgConfig_FOUND)
pkg_check_modules(ZLIB_NG QUIET zlib-ng)
if(ZLIB_NG_FOUND)
add_compile_definitions(HAVE_ZLIB_NG=1 HAVE_HTTP_COMPRESSION=1)
message(STATUS " Compression: zlib-ng ${ZLIB_NG_VERSION}")
set(_compression_ok TRUE)
else()
pkg_check_modules(ZLIB_PC QUIET zlib)
if(ZLIB_PC_FOUND)
add_compile_definitions(HAVE_HTTP_COMPRESSION=1)
message(STATUS " Compression: zlib ${ZLIB_PC_VERSION} (zlib-ng not found)")
set(_compression_ok TRUE)
endif()
endif()
endif()
if(NOT _compression_ok)
find_package(ZLIB QUIET)
if(ZLIB_FOUND)
add_compile_definitions(HAVE_HTTP_COMPRESSION=1)
message(STATUS " Compression: zlib ${ZLIB_VERSION_STRING} (system)")
set(_compression_ok TRUE)
endif()
endif()
if(_compression_ok)
set(COMPRESSION_SOURCES
src/compression/http_compression.c
src/compression/http_compression_gzip.c
src/compression/http_compression_defaults.c
src/compression/http_compression_negotiate.c
src/compression/http_compression_response.c
src/compression/http_compression_request.c
)
else()
message(STATUS " Compression: disabled (no zlib-ng or zlib found)")
endif()
endif()

# Source files - HTTP/1.1 parser
set(HTTP1_SOURCES
src/http1/http_parser.c
Expand Down Expand Up @@ -143,6 +189,7 @@ set(ALL_SOURCES
${FORMAT_SOURCES}
${LOG_SOURCES}
${LLHTTP_SOURCES}
${COMPRESSION_SOURCES}
)

# Create library target (not for actual building, just for CodeQL analysis)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<img src="https://img.shields.io/badge/PHP-8.6%2B-blue.svg" alt="PHP 8.6+"/>
<img src="https://img.shields.io/badge/HTTP-1.1%20%7C%202%20%7C%203-green.svg" alt="HTTP 1.1 | 2 | 3"/>
<img src="https://img.shields.io/badge/TLS-1.2%20%7C%201.3-green.svg" alt="TLS 1.2 | 1.3"/>
<img src="https://img.shields.io/badge/gzip-zlib--ng-green.svg" alt="gzip via zlib-ng"/>
<img src="https://img.shields.io/badge/WebSocket-RFC%206455-orange.svg" alt="WebSocket"/>
<img src="https://img.shields.io/badge/gRPC-supported-orange.svg" alt="gRPC"/>
<img src="https://img.shields.io/badge/security-audited-brightgreen.svg" alt="Security Audited"/>
Expand Down Expand Up @@ -42,6 +43,7 @@ This means you can serve a REST API over HTTP/2, push real-time events over Serv
| ✅ Ready | **Zero-copy architecture** | Minimal allocations on hot paths |
| ✅ Ready | **HTTP/2** | Multiplexing, server push (via nghttp2) |
| ✅ Ready | **HTTP/3 / QUIC** | UDP transport via ngtcp2 + nghttp3; OpenSSL 3.5 QUIC API |
| ✅ Ready | **Compression (gzip)** | Response gzip + inbound decode across H1/H2/H3 (zlib-ng / zlib). See [docs/COMPRESSION.md](docs/COMPRESSION.md). |
| 📋 Planned | **WebSocket** | RFC 6455, upgrade from HTTP/1.1 and HTTP/2, full duplex |
| 📋 Planned | **SSE (Server-Sent Events)** | RFC 8895, server-to-client event streaming |
| 📋 Planned | **gRPC** | Built on HTTP/2, unary and streaming RPC |
Expand Down
79 changes: 79 additions & 0 deletions config.m4
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ PHP_ARG_WITH([nghttp3],
[no],
[no])

PHP_ARG_ENABLE([http-compression],
[whether to enable HTTP body compression],
[AS_HELP_STRING([--enable-http-compression],
[Enable HTTP body compression (auto-detected; prefers zlib-ng, falls back to zlib; use --disable-http-compression to opt out)])],
[yes],
[no])

PHP_ARG_ENABLE([tests],
[whether to build tests],
[AS_HELP_STRING([--enable-tests],
Expand Down Expand Up @@ -309,6 +316,59 @@ if test "$PHP_HTTP_SERVER" != "no"; then
fi
fi

dnl HTTP body compression (issue #8). Default: auto-detect — enabled
dnl if zlib-ng or zlib is present. Prefers zlib-ng for ~2–4x throughput
dnl over stock zlib at the same compression level; falls back to system
dnl zlib so the build never blocks on a missing optional dependency.
dnl Fail-soft policy: if neither is found, emit a warning and leave
dnl HAVE_HTTP_COMPRESSION undefined (build completes, feature absent).
if test "$PHP_HTTP_COMPRESSION" = "yes"; then
AC_PATH_PROG([PKG_CONFIG], [pkg-config], [no])
_http_server_compression_ok=no

AC_MSG_CHECKING([for zlib-ng])
if test -x "$PKG_CONFIG" && "$PKG_CONFIG" --exists zlib-ng 2>/dev/null; then
ZLIB_NG_CFLAGS=`"$PKG_CONFIG" --cflags zlib-ng`
ZLIB_NG_LIBS=`"$PKG_CONFIG" --libs zlib-ng`
ZLIB_NG_VERSION=`"$PKG_CONFIG" --modversion zlib-ng`
AC_MSG_RESULT([yes (version $ZLIB_NG_VERSION)])
PHP_EVAL_LIBLINE($ZLIB_NG_LIBS, TRUE_ASYNC_SERVER_SHARED_LIBADD)
PHP_EVAL_INCLINE($ZLIB_NG_CFLAGS)
AC_DEFINE([HAVE_ZLIB_NG], [1], [Whether zlib-ng is available])
AC_DEFINE([HAVE_HTTP_COMPRESSION], [1], [Whether HTTP body compression is enabled])
_http_server_compression_ok=yes
else
AC_MSG_RESULT([no])

AC_MSG_CHECKING([for zlib (fallback)])
if test -x "$PKG_CONFIG" && "$PKG_CONFIG" --exists zlib 2>/dev/null; then
ZLIB_CFLAGS=`"$PKG_CONFIG" --cflags zlib`
ZLIB_LIBS=`"$PKG_CONFIG" --libs zlib`
ZLIB_VERSION=`"$PKG_CONFIG" --modversion zlib`
AC_MSG_RESULT([yes (version $ZLIB_VERSION)])
PHP_EVAL_LIBLINE($ZLIB_LIBS, TRUE_ASYNC_SERVER_SHARED_LIBADD)
PHP_EVAL_INCLINE($ZLIB_CFLAGS)
AC_DEFINE([HAVE_HTTP_COMPRESSION], [1], [Whether HTTP body compression is enabled])
_http_server_compression_ok=yes
else
dnl Last resort — many systems have libz.so without a .pc file.
AC_CHECK_LIB([z], [deflate], [
PHP_ADD_LIBRARY([z], [1], [TRUE_ASYNC_SERVER_SHARED_LIBADD])
AC_DEFINE([HAVE_HTTP_COMPRESSION], [1], [Whether HTTP body compression is enabled])
_http_server_compression_ok=yes
AC_MSG_RESULT([yes (linked via -lz)])
], [
AC_MSG_RESULT([no])
])
fi
fi

if test "$_http_server_compression_ok" != "yes"; then
AC_MSG_WARN([HTTP body compression disabled: neither zlib-ng nor zlib found. Install libzlib-ng-dev or zlib1g-dev to enable.])
PHP_HTTP_COMPRESSION=no
fi
fi

dnl Unit tests support (CMocka)
if test "$PHP_TESTS" = "yes"; then
AC_CHECK_LIB(cmocka, _cmocka_run_group_tests, [
Expand Down Expand Up @@ -371,6 +431,21 @@ if test "$PHP_HTTP_SERVER" != "no"; then
"
fi

dnl Compression sources — gated by the same PHP_HTTP_COMPRESSION=yes
dnl set by the detection block above. Layout under src/compression/
dnl mirrors src/http2/ and src/http3/ — additional codecs (Brotli,
dnl zstd) drop in here in phase 2 without touching the response path.
if test "$PHP_HTTP_COMPRESSION" = "yes"; then
http_server_sources="$http_server_sources
src/compression/http_compression.c
src/compression/http_compression_gzip.c
src/compression/http_compression_defaults.c
src/compression/http_compression_negotiate.c
src/compression/http_compression_response.c
src/compression/http_compression_request.c
"
fi

dnl HTTP/3 sources — gated by the same PHP_HTTP3=yes set by the detection
dnl block above. Files appear in the build only when H3 detection
dnl succeeded; no internal #ifdef wrap is needed.
Expand Down Expand Up @@ -428,6 +503,10 @@ if test "$PHP_HTTP_SERVER" != "no"; then
PHP_ADD_BUILD_DIR([$ext_builddir/src/http2])
fi

if test "$PHP_HTTP_COMPRESSION" = "yes"; then
PHP_ADD_BUILD_DIR([$ext_builddir/src/compression])
fi

if test "$PHP_HTTP3" = "yes"; then
PHP_ADD_BUILD_DIR([$ext_builddir/src/http3])
PHP_ADD_INCLUDE([$ext_srcdir/src/http3])
Expand Down
151 changes: 151 additions & 0 deletions docs/COMPRESSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# HTTP body compression

Phase 1 — gzip on responses + inbound request decoding, served identically
across HTTP/1.1, HTTP/2 and HTTP/3. Issue
[#8](https://github.com/true-async/server/issues/8).

## Build

`--enable-http-compression` is on by default. The build prefers
`zlib-ng` (≈2-4× the throughput of stock zlib at the same compression
level) and falls back to system `zlib` if the former is not installed.
Pass `--disable-http-compression` to opt out entirely.

```sh
./configure --enable-http-server --enable-http-compression # default
./configure --enable-http-server --disable-http-compression # off
```

## Configuration

All five knobs live on `HttpServerConfig` and freeze at
`HttpServer::__construct` — same discipline as the other config setters.

| Setter | Default | Range |
|---|---|---|
| `setCompressionEnabled(bool)` | `true` | — |
| `setCompressionLevel(int)` | `6` | 1..9 (zlib semantics) |
| `setCompressionMinSize(int)` | `1024` | 0..16 MiB |
| `setCompressionMimeTypes(array)` | text whitelist below | non-empty strings |
| `setRequestMaxDecompressedSize(int)` | `10485760` (10 MiB) | ≥ 0 (0 = no cap) |

Default MIME whitelist (replaces wholesale on `setCompressionMimeTypes`):

```
application/javascript image/svg+xml text/javascript
application/json text/css text/plain
application/xml text/html text/xml
```

`getCompressionMimeTypes()` returns the live, materialised list — what
`var_dump($cfg->getCompressionMimeTypes())` shows is exactly the policy
the negotiation code applies.

## Per-response opt-out

```php
$response->setNoCompression();
```

Overrides every other rule (Accept-Encoding negotiation, MIME match,
size threshold). Use on:

- responses that combine secrets with reflected user input (BREACH
mitigation),
- pre-compressed payloads where the handler already set
`Content-Encoding`,
- diagnostic dumps you want to read off the wire as-is.

## Negotiation

Follows RFC 9110 §12.5.3 with two pragmatic deviations:

1. **No `Accept-Encoding` header → identity only.** RFC permits any
coding in this case, but real-world clients without AE are usually
probes / scripts that may not handle gzip. Matches nginx.
2. **`identity;q=0` and `*;q=0` are honoured.** A `*;q=0` without a
later identity entry excludes identity, so the response goes out as
identity if there is no acceptable coding — the 406 path is not
taken; preference is to ship a working response.

Skip rules — when **any** of these holds, the response stays identity:

- request method is `HEAD`
- request carries a `Range` header
- response status ∈ `1xx, 204, 304`
- handler already set `Content-Encoding`
- response `Content-Type` is outside the whitelist
- response body is smaller than `compression_min_size` (buffered path
only — streaming bodies have unknown size)
- `setNoCompression()` was called on the response
- `compression_enabled` is false in the config

When compression engages, the response gets:

```
Content-Encoding: gzip
Vary: Accept-Encoding (appended if Vary already exists)
```

`Content-Length` is recomputed for buffered responses; on streaming
responses (`HttpResponse::send`) it is dropped — chunked H1 and H2
DATA framing carry length implicitly.

## Inbound (request body) decoding

`Content-Encoding: gzip` (and the legacy `x-gzip` alias) on incoming
requests is decoded transparently before the handler runs. Handlers
see `HttpRequest::getBody()` returning the decoded payload; the
`Content-Encoding` header on the request side is left intact for
diagnostic round-trip.

| Outcome | HTTP status |
|---|---|
| Unknown coding (e.g. `br`, `deflate`) | 415 Unsupported Media Type |
| Decoded size exceeds `request_max_decompressed_size` | 413 Payload Too Large |
| Corrupt inflate stream | 400 Bad Request |
| `identity` or no `Content-Encoding` header | pass-through |

## Streaming

When handlers stream via `$response->send($chunk)`, the encoder is
installed transparently on the first call (subject to negotiation).
The wrapper accumulates compressed output across an entire encoder
iteration and ships it as a single underlying chunk — one chunked-H1
size line, one H2 DATA frame per `send()` call, regardless of how many
internal inflate passes deflate needed.

`mark_ended()` (called by `$response->end()`) drains the gzip trailer
(CRC32 + ISIZE) into a final chunk before delegating to the underlying
ops.

## Engine selection

The build banner reports the chosen engine:

```
checking for zlib-ng... yes (version 2.1.0)
```

or

```
checking for zlib-ng... no
checking for zlib (fallback)... yes (version 1.3)
```

At runtime the engine is also visible via the
`http_compression_engine_name()` C symbol — `"zlib-ng"`, `"zlib"`, or
`"disabled"` when the feature is off.

## What's not in scope (yet)

Phase 2 will add Brotli (`br`) and zstd (`zstd`) backends through the
same `http_encoder_t` vtable; phase 3 covers pre-compressed static
assets (`*.gz` / `*.br` on disk, served via sendfile). Threadpool
offload for very large buffered bodies is gated on real-world latency
profiles — not added speculatively.

Strict `deflate` is intentionally skipped: half the deployed clients
send raw deflate and the other half send zlib-wrapped deflate, and
neither side reliably negotiates which is which. Use gzip.
Loading
Loading