add support for HTTP/2 (#2317)

This commit is contained in:
Athou
2026-10-13 06:34:07 +02:00
parent 2dd397757d
commit 3f6158fc47
5 changed files with 236 additions and 171 deletions

View File

@@ -456,11 +456,6 @@
<artifactId>ayza-for-apache5</artifactId>
<version>10.1.0</version>
</dependency>
<dependency>
<groupId>org.brotli</groupId>
<artifactId>dec</artifactId>
<version>0.1.2</version>
</dependency>
<dependency>
<groupId>com.github.seancfoley</groupId>
<artifactId>ipaddress</artifactId>

View File

@@ -19,27 +19,27 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.entity.DeflateInputStream;
import org.apache.hc.client5.http.entity.InputStreamFactory;
import org.apache.hc.client5.http.entity.compress.ContentCoding;
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.client5.http.protocol.RedirectStrategy;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.brotli.dec.BrotliInputStream;
import java.net.InetAddress;
import java.net.URI;
@@ -48,7 +48,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.SequencedMap;
import java.util.zip.GZIPInputStream;
import java.util.function.UnaryOperator;
@Singleton
@RequiredArgsConstructor
@@ -60,8 +60,8 @@ public class HttpClientFactory {
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
public CloseableHttpClient newClient(int poolSize) {
PoolingHttpClientConnectionManager connectionManager =
public CloseableHttpAsyncClient newClient(int poolSize) {
PoolingAsyncClientConnectionManager connectionManager =
newConnectionManager(config, poolSize);
String userAgent =
config.httpClient()
@@ -74,42 +74,46 @@ public class HttpClientFactory {
return newClient(config, connectionManager, userAgent);
}
private CloseableHttpClient newClient(
private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config,
HttpClientConnectionManager connectionManager,
PoolingAsyncClientConnectionManager connectionManager,
String userAgent) {
List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
SequencedMap<String, InputStreamFactory> contentDecoderMap = new LinkedHashMap<>();
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new);
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new);
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::new);
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
new LinkedHashMap<>();
contentDecoderMap.put(
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
RedirectStrategy redirectStrategy =
config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
: new DefaultRedirectStrategy();
return HttpClientBuilder.create()
.disableConnectionState()
.useSystemProperties()
.disableAutomaticRetries()
.disableCookieManagement()
.setUserAgent(userAgent)
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
CloseableHttpAsyncClient client =
HttpAsyncClients.custom()
.disableConnectionState()
.useSystemProperties()
.disableAutomaticRetries()
.disableCookieManagement()
.setUserAgent(userAgent)
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
client.start();
return client;
}
private PoolingHttpClientConnectionManager newConnectionManager(
private PoolingAsyncClientConnectionManager newConnectionManager(
CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
@@ -118,8 +122,8 @@ public class HttpClientFactory {
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
: DNS_RESOLVER;
return PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
return PoolingAsyncClientConnectionManagerBuilder.create()
.setTlsStrategy(Apache5SslUtils.toTlsStrategy(sslFactory))
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
@@ -129,6 +133,7 @@ public class HttpClientFactory {
.build())
.setDefaultTlsConfig(
TlsConfig.custom()
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
.setHandshakeTimeout(
Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build())

View File

@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import jakarta.inject.Singleton;
@@ -21,35 +20,44 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.client5.http.utils.DateUtils;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
import org.apache.hc.core5.util.Timeout;
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.time.InstantSource;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
/** Smart HTTP getter: handles gzip, ssl, last modified and etag headers */
/** Smart HTTP getter: handles gzip, ssl, last modified, and etag headers */
@Singleton
@Slf4j
public class HttpGetter {
private final CommaFeedConfiguration config;
private final InstantSource instantSource;
private final CloseableHttpClient client;
private final CloseableHttpAsyncClient client;
private final Cache<HttpRequest, HttpResponse> cache;
public HttpGetter(
@@ -157,63 +165,83 @@ public class HttpGetter {
.setProtocolUpgradeEnabled(false)
.build());
return client.execute(
request.toClassicHttpRequest(),
context,
resp -> {
byte[] content =
resp.getEntity() == null
? null
: toByteArray(
resp.getEntity(),
config.httpClient().maxResponseSize().asLongValue());
int code = resp.getCode();
String lastModifiedHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String eTagHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
new BasicResponseConsumer<>(
new LimitedBytesEntityConsumer(
config.httpClient().maxResponseSize().asLongValue()));
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
new FutureCallback<>() {
@Override
public void completed(
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
CacheControl cacheControl =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl)
.orElse(null);
@Override
public void failed(Exception ex) {}
Instant retryAfter =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(this::toInstant)
.orElse(null);
@Override
public void cancelled() {}
};
Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
client.execute(req, resp, context, callback);
String contentType =
Optional.ofNullable(resp.getEntity())
.map(HttpEntity::getContentType)
.orElse(null);
String urlAfterRedirect =
Optional.ofNullable(context.getRedirectLocations())
.map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null))
.map(URI::toString)
.orElse(request.getUrl());
try {
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message = future.get();
return new HttpResponse(
code,
lastModifiedHeader,
eTagHeader,
cacheControl,
retryAfter,
content,
contentType,
urlAfterRedirect);
});
byte[] content = message.getBody();
int code = message.getHead().getCode();
String lastModifiedHeader =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String eTagHeader =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
CacheControl cacheControl =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CACHE_CONTROL))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl)
.orElse(null);
Instant retryAfter =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.RETRY_AFTER))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(this::toInstant)
.orElse(null);
String contentType =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String urlAfterRedirect =
Optional.ofNullable(context.getRedirectLocations())
.map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null))
.map(URI::toString)
.orElse(request.getUrl());
return new HttpResponse(
code,
lastModifiedHeader,
eTagHeader,
cacheControl,
retryAfter,
content,
contentType,
urlAfterRedirect);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("interrupted while fetching " + request.getUrl(), e);
} catch (ExecutionException e) {
throw Lombok.sneakyThrow(e.getCause());
}
}
private static CacheControl toCacheControl(String headerValue) {
@@ -237,28 +265,6 @@ public class HttpGetter {
return DateUtils.parseStandardDate(headerValue);
}
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
if (entity.getContentLength() > maxBytes) {
throw new IOException(
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
.formatted(entity.getContentLength(), maxBytes));
}
try (InputStream input = entity.getContent()) {
if (input == null) {
return null;
}
byte[] bytes = ByteStreams.limit(input, maxBytes + 1).readAllBytes();
if (bytes.length > maxBytes) {
throw new IOException(
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
}
return bytes;
}
}
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
HttpClientCache cacheConfig = config.httpClient().cache();
if (!cacheConfig.enabled()) {
@@ -274,6 +280,43 @@ public class HttpGetter {
.build();
}
@RequiredArgsConstructor
private static class LimitedBytesEntityConsumer extends AbstractBinAsyncEntityConsumer<byte[]> {
private final long maxBytes;
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
@Override
protected void streamStart(ContentType contentType) {}
@Override
protected int capacityIncrement() {
return Integer.MAX_VALUE;
}
@Override
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
if (out.size() + src.remaining() > maxBytes) {
throw new IOException(
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
}
byte[] chunk = new byte[src.remaining()];
src.get(chunk);
out.write(chunk, 0, chunk.length);
}
@Override
protected byte[] generateContent() {
return out.toByteArray();
}
@Override
public void releaseResources() {
out.reset();
}
}
public static class SchemeNotAllowedException extends Exception {
private static final long serialVersionUID = 1L;
@@ -336,15 +379,15 @@ public class HttpGetter {
return new HttpRequestBuilder().url(url);
}
public ClassicHttpRequest toClassicHttpRequest() {
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
public SimpleHttpRequest toSimpleHttpRequest() {
SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
if (lastModified != null) {
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
}
if (eTag != null) {
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
builder.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
}
return req;
return builder.build();
}
}

View File

@@ -16,26 +16,28 @@ import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.net.WWWFormCodec;
import org.apache.hc.core5.util.Timeout;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
@Singleton
@Slf4j
public class PushNotificationService {
private final CloseableHttpClient httpClient;
private final CloseableHttpAsyncClient httpClient;
private final Meter meter;
private final CommaFeedConfiguration config;
@@ -97,13 +99,13 @@ public class PushNotificationService {
return;
}
HttpPost request = new HttpPost(serverUrl + "/" + topic);
request.setConfig(
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/" + topic);
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.addHeader("Title", feedTitle);
request.setEntity(new StringEntity(entryTitle, StandardCharsets.UTF_8));
request.setBody(entryTitle, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(entryUrl)) {
request.addHeader("Click", entryUrl);
@@ -113,15 +115,7 @@ public class PushNotificationService {
request.addHeader("Authorization", "Bearer " + settings.getUserSecret());
}
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"ntfy notification failed with status " + response.getCode());
}
return null;
});
send("ntfy", request.build());
}
private void sendGotify(
@@ -152,23 +146,15 @@ public class PushNotificationService {
.put("click", new JsonObject().put("url", entryUrl))));
}
HttpPost request = new HttpPost(serverUrl + "/message");
request.setConfig(
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.addHeader("X-Gotify-Key", token);
request.setEntity(new StringEntity(json.toString(), ContentType.APPLICATION_JSON));
request.setBody(json.toString(), ContentType.APPLICATION_JSON);
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"gotify notification failed with status " + response.getCode());
}
return null;
});
send("gotify", request.build());
}
private void sendPushover(
@@ -194,22 +180,32 @@ public class PushNotificationService {
params.add(new BasicNameValuePair("url", entryUrl));
}
HttpPost request = new HttpPost("https://api.pushover.net/1/messages.json");
request.setConfig(
SimpleRequestBuilder request =
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
request.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
request.setBody(
WWWFormCodec.format(params, StandardCharsets.UTF_8),
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"pushover notification failed with status " + response.getCode());
}
return null;
});
send("pushover", request.build());
}
private void send(String name, SimpleHttpRequest request) throws IOException {
try {
SimpleHttpResponse response = httpClient.execute(request, null).get();
if (response.getCode() >= 400) {
throw new PushNotificationException(
name + " notification failed with status " + response.getCode());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while sending " + name + " notification", e);
} catch (ExecutionException e) {
throw new IOException(e.getCause());
}
}
public static class PushNotificationException extends RuntimeException {

View File

@@ -29,6 +29,7 @@ import org.mockserver.model.Delay;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
import org.mockserver.model.MediaType;
import org.mockserver.model.Protocol;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -359,8 +360,7 @@ class HttpGetterTest {
IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(
"Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)",
e.getMessage());
"Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage());
}
@Test
@@ -416,7 +416,7 @@ class HttpGetterTest {
@Nested
class Compression {
private static final String ACCEPT_ENCODING = "gzip, deflate, br";
private static final String ACCEPT_ENCODING = "deflate, gzip";
@Test
void gzip() throws Exception {
@@ -590,4 +590,30 @@ class HttpGetterTest {
UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
}
}
@Nested
class Protocols {
@Test
void usesHttp2OverTls() throws Exception {
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_2))
.respond(HttpResponse.response().withBody("h2"));
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
.respond(HttpResponse.response().withBody("h1"));
HttpResult result = getter.get("https://localhost:" + mockServerClient.getPort());
Assertions.assertEquals("h2", new String(result.content()));
}
@Test
void fallsBackToHttp1OverCleartext() throws Exception {
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
.respond(HttpResponse.response().withBody("h1"));
HttpResult result = getter.get("http://localhost:" + mockServerClient.getPort());
Assertions.assertEquals("h1", new String(result.content()));
}
}
}