diff --git a/httpclient5-testing/pom.xml b/httpclient5-testing/pom.xml index 84e1ef5bba..dbb066c27c 100644 --- a/httpclient5-testing/pom.xml +++ b/httpclient5-testing/pom.xml @@ -77,6 +77,11 @@ httpclient5-fluent test + + org.apache.httpcomponents.client5 + httpclient5-websocket + test + com.kohlschutter.junixsocket junixsocket-core @@ -113,6 +118,16 @@ junit-jupiter test + + org.eclipse.jetty + jetty-servlet + test + + + org.eclipse.jetty.websocket + websocket-server + test + diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java new file mode 100644 index 0000000000..8f485e2edf --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java @@ -0,0 +1,530 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.client5.http.websocket.client.WebSocketClients; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.api.extensions.ExtensionConfig; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +final class WebSocketClientTest { + + private Server server; + private int port; + private final AtomicBoolean pmceNegotiated = new AtomicBoolean(false); + + @BeforeEach + void startServer() throws Exception { + server = new Server(); + final ServerConnector connector = new ServerConnector(server); + connector.setPort(0); // auto-bind free port + server.addConnector(connector); + + final ServletContextHandler ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + ctx.addServlet(new ServletHolder(new EchoServlet()), "/echo"); + ctx.addServlet(new ServletHolder(new PmceServlet(pmceNegotiated)), "/pmce"); + ctx.addServlet(new ServletHolder(new InterleaveServlet()), "/interleave"); + ctx.addServlet(new ServletHolder(new AbruptServlet()), "/abrupt"); + ctx.addServlet(new ServletHolder(new TooBigServlet()), "/too-big"); + server.setHandler(ctx); + + server.start(); + port = connector.getLocalPort(); + } + + @AfterEach + void stopServer() throws Exception { + if (server != null) { + server.stop(); + } + } + + private static URI uri(final int port, final String path) { + return URI.create("ws://localhost:" + port + path); + } + + private static CloseableWebSocketClient newClient() { + final CloseableWebSocketClient client = WebSocketClientBuilder.create().build(); + client.start(); // start reactor threads + return client; + } + + @Test + void echo_uncompressed() throws Exception { + final URI uri = uri(port, "/echo"); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) { + client.start(); + + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + final StringBuilder echoed = new StringBuilder(); + final AtomicReference wsRef = new AtomicReference<>(); + + System.out.println("[TEST] connecting: " + uri); + + final WebSocketListener listener = new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + wsRef.set(ws); + final String payload = buildPayload(); + System.out.println("[TEST] open: " + uri); + final boolean sent = ws.sendText(payload, true); + System.out.println("[TEST] sent (chars=" + payload.length() + ") sent=" + sent); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + if (last) { + System.out.println("[TEST] text (chars=" + text.length() + "): " + + (text.length() > 80 ? text.subSequence(0, 80) + "…" : text)); + final WebSocket ws = wsRef.get(); + if (ws != null) { + ws.close(1000, "done"); + } + } + } + + @Override + public void onClose(final int code, final String reason) { + try { + System.out.println("[TEST] close: " + code + " " + reason); + assertEquals(1000, code); + assertTrue(echoed.length() > 0, "No text echoed back"); + } finally { + done.countDown(); + } + } + + @Override + public void onError(final Throwable ex) { + ex.printStackTrace(System.out); + errorRef.set(ex); + done.countDown(); + } + + private String buildPayload() { + final String base = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder buf = new StringBuilder(); + for (int i = 0; i < 256; i++) { + buf.append(base); + } + return buf.toString(); + } + + }; + + final CompletableFuture future = client.connect(uri, listener, cfg, null); + future.whenComplete((ws, ex) -> { + if (ex != null) { + errorRef.set(ex); + done.countDown(); + } + }); + + assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + } + } + + @Test + void echo_compressed_pmce() throws Exception { + final URI uri = uri(port, "/pmce"); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(true) + .offerServerNoContextTakeover(true) + .offerClientNoContextTakeover(true) + .offerClientMaxWindowBits(15) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) { + client.start(); + + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + final StringBuilder echoed = new StringBuilder(); + final AtomicReference wsRef = new AtomicReference<>(); + + final WebSocketListener listener = new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + wsRef.set(ws); + final String payload = "pmce test " + Instant.now(); + ws.sendText(payload, true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + if (last) { + final WebSocket ws = wsRef.get(); + if (ws != null) { + ws.close(1000, "done"); + } + } + } + + @Override + public void onClose(final int code, final String reason) { + try { + assertEquals(1000, code); + assertTrue(pmceNegotiated.get(), "PMCE not negotiated on server"); + assertTrue(echoed.length() > 0, "No text echoed back"); + } finally { + done.countDown(); + } + } + + @Override + public void onError(final Throwable ex) { + errorRef.set(ex); + done.countDown(); + } + }; + + client.connect(uri, listener, cfg, null); + assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + } + } + + @Test + void ping_interleaved_fragmentation() throws Exception { + final CountDownLatch gotText = new CountDownLatch(1); + final CountDownLatch gotPong = new CountDownLatch(1); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + final URI u = uri(port, "/interleave"); + client.connect(u, new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + ws.ping(null); + final String prefix = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 256; i++) { + sb.append(prefix); + } + ws.sendText(sb.toString(), true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + gotText.countDown(); + } + + @Override + public void onPong(final ByteBuffer payload) { + gotPong.countDown(); + } + + @Override + public void onClose(final int code, final String reason) { + // the servlet closes after echo + } + + @Override + public void onError(final Throwable ex) { + gotText.countDown(); + gotPong.countDown(); + } + }, cfg, null); + + assertTrue(gotPong.await(10, TimeUnit.SECONDS), "did not receive PONG"); + assertTrue(gotText.await(10, TimeUnit.SECONDS), "did not receive TEXT"); + } + } + + @Test + void max_message_1009() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference codeRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + final int maxMessage = 2048; // 2 KiB + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setMaxMessageSize(maxMessage) + .enablePerMessageDeflate(false) + .build(); + + final URI u = uri(port, "/too-big"); + client.connect(u, new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + // Trigger the server to send an oversized text message. + ws.sendText("trigger-too-big", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + // We may or may not see some text before the 1009 close. + } + + @Override + public void onClose(final int code, final String reason) { + codeRef.set(code); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + errorRef.set(ex); + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(10, TimeUnit.SECONDS), "timeout waiting for 1009 close"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + + assertEquals(Integer.valueOf(1009), codeRef.get(), "expected 1009 close code"); + } + } + + @Test + void abnormal_close_1006() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + + final URI u = uri(port, "/abrupt"); + client.connect(u, new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + // do nothing; server will disconnect abruptly + } + + @Override + public void onClose(final int code, final String reason) { + assertEquals(1006, code); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + // acceptable; still expect onClose(1006) + } + }, cfg, null); + + assertTrue(done.await(10, TimeUnit.SECONDS), "did not see 1006 abnormal closure"); + } + } + + public static final class EchoServlet extends WebSocketServlet { + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30000); + factory.setCreator((req, resp) -> new EchoSocket()); + } + } + + public static final class EchoSocket extends WebSocketAdapter { + @Override + public void onWebSocketText(final String msg) { + final Session s = getSession(); + if (s != null && s.isOpen()) { + s.getRemote().sendString(msg, null); + s.close(1000, "done"); + } + } + } + + public static final class PmceServlet extends WebSocketServlet { + private final AtomicBoolean negotiated; + + public PmceServlet(final AtomicBoolean negotiated) { + this.negotiated = negotiated; + } + + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30000); + factory.setCreator((req, resp) -> new PmceSocket(negotiated)); + } + } + + public static final class PmceSocket extends WebSocketAdapter { + private final AtomicBoolean negotiated; + + public PmceSocket(final AtomicBoolean negotiated) { + this.negotiated = negotiated; + } + + @Override + public void onWebSocketConnect(final Session sess) { + super.onWebSocketConnect(sess); + if (sess != null) { + final java.util.List exts = sess.getUpgradeRequest().getExtensions(); + boolean hasPmce = false; + if (exts != null) { + for (final ExtensionConfig ext : exts) { + if ("permessage-deflate".equalsIgnoreCase(ext.getName())) { + hasPmce = true; + break; + } + } + } + negotiated.set(hasPmce); + } + } + + @Override + public void onWebSocketText(final String msg) { + final Session s = getSession(); + if (s != null && s.isOpen()) { + s.getRemote().sendString(msg, null); + s.close(1000, "done"); + } + } + } + + public static final class InterleaveServlet extends WebSocketServlet { + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30000); + factory.setCreator((req, resp) -> new InterleaveSocket()); + } + } + + public static final class InterleaveSocket extends WebSocketAdapter { + @Override + public void onWebSocketText(final String msg) { + final Session s = getSession(); + if (s == null) { + return; + } + try { + s.getRemote().sendPing(ByteBuffer.wrap(new byte[]{'p', 'i', 'n', 'g'})); + } catch (final IOException e) { + throw new RuntimeException(e); + } + s.getRemote().sendString(msg, null); + } + } + + public static final class AbruptServlet extends WebSocketServlet { + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30000); + factory.setCreator((req, resp) -> new AbruptSocket()); + } + } + + public static final class AbruptSocket extends WebSocketAdapter { + @Override + public void onWebSocketConnect(final Session sess) { + super.onWebSocketConnect(sess); + // Immediately drop the TCP connection without sending a CLOSE frame. + try { + sess.disconnect(); + } catch (final Throwable ignore) { + // ignore + } + } + } + + public static final class TooBigServlet extends WebSocketServlet { + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30000); + factory.setCreator((req, resp) -> new TooBigSocket()); + } + } + + public static final class TooBigSocket extends WebSocketAdapter { + @Override + public void onWebSocketText(final String msg) { + final Session sess = getSession(); + if (sess == null || !sess.isOpen()) { + return; + } + final StringBuilder sb = new StringBuilder(); + final String chunk = "1234567890abcdef-"; + // Build something comfortably larger than the maxMessage (2 KiB in the test) + while (sb.length() <= 8192) { + sb.append(chunk); + } + final String big = sb.toString(); + sess.getRemote().sendString(big, null); + // No CLOSE here; the client must decide to close with 1009. + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java new file mode 100644 index 0000000000..09917551ac --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java @@ -0,0 +1,100 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; + +public final class JettyEchoServer { + private final Server server = new Server(); + private int port; + + public void start() throws Exception { + // Ephemeral port + final ServerConnector connector = new ServerConnector(server); + connector.setPort(0); + server.setConnectors(new Connector[]{connector}); + + // Context + WebSocket servlet at /echo + final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + context.addServlet(EchoServlet.class, "/echo"); + server.setHandler(context); + + server.start(); + this.port = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + public void stop() throws Exception { + server.stop(); + server.destroy(); + } + + public String uri() { + return "ws://127.0.0.1:" + port + "/echo"; + } + + public static class EchoServlet extends WebSocketServlet { + private static final long serialVersionUID = 1L; + + @Override + public void configure(final WebSocketServletFactory factory) { + // PMCE (permessage-deflate) is available by default in Jetty 9.4 when on classpath. + // No need to call the deprecated getExtensionFactory(). + factory.getPolicy().setMaxTextMessageSize(65536); + factory.getPolicy().setMaxBinaryMessageSize(65536); + factory.register(EchoSocket.class); + } + } + + @WebSocket + public static class EchoSocket { + @OnWebSocketMessage + public void onText(final Session session, final String message) { + try { + session.getRemote().sendString(message); + } catch (final IOException ignore) { } + } + + @OnWebSocketMessage + public void onBinary(final Session session, final byte[] payload, final int offset, final int len) { + try { + session.getRemote().sendBytes(ByteBuffer.wrap(payload, offset, len)); + } catch (final IOException ignore) { } + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java new file mode 100644 index 0000000000..035eb021b9 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java @@ -0,0 +1,274 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; + +public final class WsPerfRunner { + + public static void main(final String[] args) throws Exception { + final Args a = Args.parse(args); + System.out.printf(Locale.ROOT, + "mode=%s uri=%s clients=%d durationSec=%d bytes=%d inflight=%d pmce=%s compressible=%s%n", + a.mode, a.uri, a.clients, a.durationSec, a.bytes, a.inflight, a.pmce, a.compressible); + + final ExecutorService pool = Executors.newFixedThreadPool(Math.min(a.clients, 64)); + final AtomicLong sends = new AtomicLong(); + final AtomicLong recvs = new AtomicLong(); + final AtomicLong errors = new AtomicLong(); + final ConcurrentLinkedQueue lats = new ConcurrentLinkedQueue<>(); + final CountDownLatch ready = new CountDownLatch(a.clients); + final CountDownLatch done = new CountDownLatch(a.clients); + + final byte[] payload = a.compressible ? makeCompressible(a.bytes) : makeRandom(a.bytes); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(a.durationSec); + + for (int i = 0; i < a.clients; i++) { + final int id = i; + pool.submit(() -> runClient(id, a, payload, sends, recvs, errors, lats, ready, done, deadline)); + } + + ready.await(); // all connected + done.await(); // test finished + pool.shutdown(); + + final long totalRecv = recvs.get(); + final long totalSend = sends.get(); + final double secs = a.durationSec; + final double msgps = totalRecv / secs; + final double mbps = (totalRecv * (long) a.bytes) / (1024.0 * 1024.0) / secs; + + System.out.printf(Locale.ROOT, "sent=%d recv=%d errors=%d%n", totalSend, totalRecv, errors.get()); + System.out.printf(Locale.ROOT, "throughput: %.0f msg/s, %.2f MiB/s%n", msgps, mbps); + + if (!lats.isEmpty()) { + final long[] arr = lats.stream().mapToLong(Long::longValue).toArray(); + Arrays.sort(arr); + System.out.printf(Locale.ROOT, + "latency (ms): p50=%.3f p95=%.3f p99=%.3f max=%.3f samples=%d%n", + nsToMs(p(arr, 0.50)), nsToMs(p(arr, 0.95)), nsToMs(p(arr, 0.99)), nsToMs(arr[arr.length - 1]), arr.length); + } + } + + private static void runClient( + final int id, final Args a, final byte[] payload, + final AtomicLong sends, final AtomicLong recvs, final AtomicLong errors, + final ConcurrentLinkedQueue lats, + final CountDownLatch ready, final CountDownLatch done, final long deadlineNanos) { + + // Per-connection WebSocket config + final WebSocketClientConfig.Builder b = WebSocketClientConfig.custom() + .setConnectTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5)) +// .setExchangeTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5)) + .setCloseWaitTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(3)) + .setOutgoingChunkSize(4096) + .setAutoPong(true); + + if (a.pmce) { + b.enablePerMessageDeflate(true) + .offerClientNoContextTakeover(false) + .offerServerNoContextTakeover(false) + .offerClientMaxWindowBits(null) + .offerServerMaxWindowBits(null); + } + final WebSocketClientConfig cfg = b.build(); + + // Build a client instance (closeable) with our default per-connection config + try (final CloseableWebSocketClient client = + WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + final AtomicInteger inflight = new AtomicInteger(); + final AtomicBoolean open = new AtomicBoolean(false); + + final CompletableFuture cf = client.connect( + URI.create(a.uri), + new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + open.set(true); + ready.countDown(); + // Prime in-flight + for (int j = 0; j < a.inflight; j++) { + sendOne(ws, a, payload, sends, inflight); + } + } + + @Override + public void onBinary(final ByteBuffer p, final boolean last) { + final long t1 = System.nanoTime(); + if (a.mode == Mode.LATENCY) { + if (p.remaining() >= 8) { + final long t0 = p.getLong(p.position()); + lats.add(t1 - t0); + } + } + recvs.incrementAndGet(); + inflight.decrementAndGet(); + } + + @Override + public void onError(final Throwable ex) { + errors.incrementAndGet(); + } + + @Override + public void onClose(final int code, final String reason) { + open.set(false); + } + }); + + try { + final WebSocket ws = cf.get(15, TimeUnit.SECONDS); + + // Main loop: keep target inflight until deadline + while (System.nanoTime() < deadlineNanos) { + while (open.get() && inflight.get() < a.inflight) { + sendOne(ws, a, payload, sends, inflight); + } + // backoff a bit + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + + // Drain a bit more + Thread.sleep(200); + ws.close(1000, "bye"); + } catch (final Exception e) { + errors.incrementAndGet(); + // If connect failed early, make sure the "all connected" gate doesn't block the whole run + ready.countDown(); + } finally { + done.countDown(); + } + } catch (final Exception ignore) { + } + } + + private static void sendOne(final WebSocket ws, final Args a, final byte[] payload, + final AtomicLong sends, final AtomicInteger inflight) { + final ByteBuffer p = ByteBuffer.allocate(payload.length + 8); + final long t0 = System.nanoTime(); + p.putLong(t0).put(payload).flip(); + if (ws.sendBinary(p, true)) { + inflight.incrementAndGet(); + sends.incrementAndGet(); + } + } + + + private enum Mode { THROUGHPUT, LATENCY } + + private static final class Args { + String uri = "ws://localhost:8080/echo"; + int clients = 8; + int durationSec = 15; + int bytes = 512; + int inflight = 32; + boolean pmce = false; + boolean compressible = true; + Mode mode = Mode.THROUGHPUT; + + static Args parse(final String[] a) { + final Args r = new Args(); + for (final String s : a) { + final String[] kv = s.split("=", 2); + if (kv.length != 2) { + continue; + } + switch (kv[0]) { + case "uri": + r.uri = kv[1]; + break; + case "clients": + r.clients = Integer.parseInt(kv[1]); + break; + case "durationSec": + r.durationSec = Integer.parseInt(kv[1]); + break; + case "bytes": + r.bytes = Integer.parseInt(kv[1]); + break; + case "inflight": + r.inflight = Integer.parseInt(kv[1]); + break; + case "pmce": + r.pmce = Boolean.parseBoolean(kv[1]); + break; + case "compressible": + r.compressible = Boolean.parseBoolean(kv[1]); + break; + case "mode": + r.mode = Mode.valueOf(kv[1]); + break; + } + } + return r; + } + } + + private static byte[] makeCompressible(final int n) { + final byte[] b = new byte[n]; + Arrays.fill(b, (byte) 'A'); + return b; + } + + private static byte[] makeRandom(final int n) { + final byte[] b = new byte[n]; + ThreadLocalRandom.current().nextBytes(b); + return b; + } + + private static double nsToMs(final long ns) { + return ns / 1_000_000.0; + } + + private static long p(final long[] arr, final double q) { + final int i = (int) Math.min(arr.length - 1, Math.max(0, Math.round((arr.length - 1) * q))); + return arr[i]; + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java new file mode 100644 index 0000000000..ad22147694 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java @@ -0,0 +1,93 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +@Disabled("Performance runner; not part of the unit test suite") +class WsPerfRunnerIT { + + private static JettyEchoServer srv; + + @BeforeAll + static void up() throws Exception { + srv = new JettyEchoServer(); + srv.start(); + } + + @AfterAll + static void down() throws Exception { + srv.stop(); + } + + @Test + void throughput_sample() throws Exception { + WsPerfRunner.main(new String[]{ + "mode=THROUGHPUT", + "uri=" + srv.uri(), // e.g., ws://127.0.0.1:PORT/ + "clients=12", + "durationSec=10", + "bytes=512", + "inflight=32", + "pmce=true", + "compressible=true" + }); + } + + + @Test + void latency_sample() throws Exception { + WsPerfRunner.main(new String[]{ + "mode=LATENCY", + "uri=" + srv.uri(), + "clients=4", + "durationSec=10", + "bytes=64", + "inflight=4", + "pmce=false", + "compressible=false" + }); + } + + @Test + void throughput_non_compressible_sample() throws Exception { + WsPerfRunner.main(new String[]{ + "mode=THROUGHPUT", + "uri=" + srv.uri(), + "clients=12", + "durationSec=10", + "bytes=512", + "inflight=32", + // PMCE negotiated, but payload is high-entropy (random-ish) + "pmce=true", + "compressible=false" + }); + } +} diff --git a/httpclient5-websocket/pom.xml b/httpclient5-websocket/pom.xml new file mode 100644 index 0000000000..247f9e50c2 --- /dev/null +++ b/httpclient5-websocket/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + org.apache.httpcomponents.client5 + httpclient5-parent + 5.7-alpha1-SNAPSHOT + + + httpclient5-websocket + Apache HttpClient WebSocket + WebSocket support for HttpClient + jar + + + org.apache.httpcomponents.client5.websocket + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.client5 + httpclient5-cache + + + org.apache.httpcomponents.core5 + httpcore5-websocket + + + org.apache.httpcomponents.core5 + httpcore5-h2 + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-slf4j-impl + true + test + + + org.apache.logging.log4j + log4j-core + test + + + org.junit.jupiter + junit-jupiter + test + + + org.apache.commons + commons-compress + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.eclipse.jetty + jetty-server + test + + + org.eclipse.jetty + jetty-servlet + test + + + org.eclipse.jetty.websocket + websocket-server + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + com.github.siom79.japicmp + japicmp-maven-plugin + + true + + + + + \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java new file mode 100644 index 0000000000..10a72aa4ec --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java @@ -0,0 +1,144 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Client-side representation of a single WebSocket connection. + * + *

Instances of this interface are thread-safe. Outbound operations may be + * invoked from arbitrary application threads. Inbound events are delivered + * to the associated {@link WebSocketListener}.

+ * + *

Outbound calls return {@code true} when the frame has been accepted for + * transmission. They do not indicate that the peer has received or processed + * the message. Applications that require acknowledgements must implement them + * at the protocol layer.

+ * + *

The close handshake follows RFC 6455. Applications should call + * {@link #close(int, String)} and wait for the {@link WebSocketListener#onClose(int, String)} + * callback to consider the connection terminated.

+ * + * @since 5.7 + */ +public interface WebSocket { + + /** + * Returns {@code true} if the WebSocket is still open and not in the + * process of closing. + */ + boolean isOpen(); + + /** + * Sends a PING control frame with the given payload. + *

The payload size must not exceed 125 bytes.

+ * + * @param data optional payload buffer; may be {@code null}. + * @return {@code true} if the frame was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean ping(ByteBuffer data); + + /** + * Sends a PONG control frame with the given payload. + *

The payload size must not exceed 125 bytes.

+ * + * @param data optional payload buffer; may be {@code null}. + * @return {@code true} if the frame was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean pong(ByteBuffer data); + + /** + * Sends a text message fragment. + * + * @param data text data to send. Must not be {@code null}. + * @param finalFragment {@code true} if this is the final fragment of + * the message, {@code false} if more fragments + * will follow. + * @return {@code true} if the fragment was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendText(CharSequence data, boolean finalFragment); + + /** + * Sends a binary message fragment. + * + * @param data binary data to send. Must not be {@code null}. + * @param finalFragment {@code true} if this is the final fragment of + * the message, {@code false} if more fragments + * will follow. + * @return {@code true} if the fragment was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendBinary(ByteBuffer data, boolean finalFragment); + + /** + * Sends a batch of text fragments as a single message. + * + * @param fragments ordered list of fragments; must not be {@code null} + * or empty. + * @param finalFragment {@code true} if this batch completes the logical + * message, {@code false} if subsequent batches + * will follow. + * @return {@code true} if the batch was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendTextBatch(List fragments, boolean finalFragment); + + /** + * Sends a batch of binary fragments as a single message. + * + * @param fragments ordered list of fragments; must not be {@code null} + * or empty. + * @param finalFragment {@code true} if this batch completes the logical + * message, {@code false} if subsequent batches + * will follow. + * @return {@code true} if the batch was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendBinaryBatch(List fragments, boolean finalFragment); + + /** + * Initiates the WebSocket close handshake. + * + *

The returned future is completed once the close frame has been + * queued for sending. It does not wait for the peer's close + * frame or for the underlying TCP connection to be closed.

+ * + * @param statusCode close status code to send. + * @param reason optional close reason; may be {@code null}. + * @return a future that completes when the close frame has been + * enqueued, or completes exceptionally if the close + * could not be initiated. + */ + CompletableFuture close(int statusCode, String reason); +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java new file mode 100644 index 0000000000..cabb90ff72 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java @@ -0,0 +1,607 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; + +/** + * Immutable configuration for {@link WebSocket} clients. + * + *

Instances are normally created via the associated builder. The + * configuration controls timeouts, maximum frame and message sizes, + * fragmentation behaviour, buffer pooling and optional automatic + * responses to PING frames.

+ * + *

Unless explicitly overridden, reasonable defaults are selected for + * desktop and server environments. For mobile or memory-constrained + * deployments, consider adjusting buffer sizes and queue limits.

+ * + * @since 5.7 + */ +public final class WebSocketClientConfig { + + private final Timeout connectTimeout; + private final List subprotocols; + + // PMCE offer + private final boolean perMessageDeflateEnabled; + private final boolean offerServerNoContextTakeover; + private final boolean offerClientNoContextTakeover; + private final Integer offerClientMaxWindowBits; + private final Integer offerServerMaxWindowBits; + + // Framing / flow + private final int maxFrameSize; + private final int outgoingChunkSize; + private final int maxFramesPerTick; + + // Buffers / pool + private final int ioPoolCapacity; + private final boolean directBuffers; + + // Behavior + private final boolean autoPong; + private final Timeout closeWaitTimeout; + private final long maxMessageSize; + private final boolean http2Enabled; + + // Outbound control queue + private final int maxOutboundControlQueue; + + private WebSocketClientConfig( + final Timeout connectTimeout, + final List subprotocols, + final boolean perMessageDeflateEnabled, + final boolean offerServerNoContextTakeover, + final boolean offerClientNoContextTakeover, + final Integer offerClientMaxWindowBits, + final Integer offerServerMaxWindowBits, + final int maxFrameSize, + final int outgoingChunkSize, + final int maxFramesPerTick, + final int ioPoolCapacity, + final boolean directBuffers, + final boolean autoPong, + final Timeout closeWaitTimeout, + final long maxMessageSize, + final int maxOutboundControlQueue, + final boolean http2Enabled) { + + this.connectTimeout = connectTimeout; + this.subprotocols = subprotocols != null + ? Collections.unmodifiableList(new ArrayList<>(subprotocols)) + : Collections.emptyList(); + this.perMessageDeflateEnabled = perMessageDeflateEnabled; + this.offerServerNoContextTakeover = offerServerNoContextTakeover; + this.offerClientNoContextTakeover = offerClientNoContextTakeover; + this.offerClientMaxWindowBits = offerClientMaxWindowBits; + this.offerServerMaxWindowBits = offerServerMaxWindowBits; + this.maxFrameSize = maxFrameSize; + this.outgoingChunkSize = outgoingChunkSize; + this.maxFramesPerTick = maxFramesPerTick; + this.ioPoolCapacity = ioPoolCapacity; + this.directBuffers = directBuffers; + this.autoPong = autoPong; + this.closeWaitTimeout = Args.notNull(closeWaitTimeout, "closeWaitTimeout"); + this.maxMessageSize = maxMessageSize; + this.maxOutboundControlQueue = maxOutboundControlQueue; + this.http2Enabled = http2Enabled; + } + + /** + * Timeout used for establishing the initial TCP/TLS connection. + * + * @return connection timeout, may be {@code null} if the caller wants to rely on defaults + * @since 5.7 + */ + public Timeout getConnectTimeout() { + return connectTimeout; + } + + /** + * Ordered list of WebSocket subprotocols offered to the server via {@code Sec-WebSocket-Protocol}. + * + *

The server may select at most one. The client should treat a server-selected protocol that + * was not offered as a handshake failure.

+ * + * @return immutable list of offered subprotocols (never {@code null}) + * @since 5.7 + */ + public List getSubprotocols() { + return subprotocols; + } + + /** + * Whether the client offers the {@code permessage-deflate} extension during the handshake. + * + * @return {@code true} if PMCE is offered, {@code false} otherwise + * @since 5.7 + */ + public boolean isPerMessageDeflateEnabled() { + return perMessageDeflateEnabled; + } + + /** + * Whether the client offers the {@code server_no_context_takeover} PMCE parameter. + * + * @return {@code true} if the parameter is included in the offer + * @since 5.7 + */ + public boolean isOfferServerNoContextTakeover() { + return offerServerNoContextTakeover; + } + + /** + * Whether the client offers the {@code client_no_context_takeover} PMCE parameter. + * + * @return {@code true} if the parameter is included in the offer + * @since 5.7 + */ + public boolean isOfferClientNoContextTakeover() { + return offerClientNoContextTakeover; + } + + /** + * Optional value for {@code client_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. The client encoder + * currently supports only {@code 15} due to JDK Deflater limitations.

+ * + * @return offered {@code client_max_window_bits}, or {@code null} if not offered + * @since 5.7 + */ + public Integer getOfferClientMaxWindowBits() { + return offerClientMaxWindowBits; + } + + /** + * Optional value for {@code server_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. This value limits the + * server's compressor; the client decoder can accept any 8..15 value.

+ * + * @return offered {@code server_max_window_bits}, or {@code null} if not offered + * @since 5.7 + */ + public Integer getOfferServerMaxWindowBits() { + return offerServerMaxWindowBits; + } + + /** + * Maximum accepted WebSocket frame payload size. + * + *

If an incoming frame exceeds this limit, the implementation should treat it as a protocol + * violation and initiate a close with an appropriate close code.

+ * + * @return maximum frame payload size in bytes (must be > 0) + * @since 5.7 + */ + public int getMaxFrameSize() { + return maxFrameSize; + } + + /** + * Preferred outgoing fragmentation chunk size. + * + *

Outgoing messages larger than this value may be fragmented into multiple frames.

+ * + * @return outgoing chunk size in bytes (must be > 0) + * @since 5.7 + */ + public int getOutgoingChunkSize() { + return outgoingChunkSize; + } + + /** + * Limit of frames written per reactor "tick". + * + *

This is a fairness control to reduce the risk of starving the reactor thread when + * a large backlog exists.

+ * + * @return maximum frames per tick (must be > 0) + * @since 5.7 + */ + public int getMaxFramesPerTick() { + return maxFramesPerTick; + } + + /** + * Capacity of the internal buffer pool used by WebSocket I/O. + * + * @return pool capacity (must be > 0) + * @since 5.7 + */ + public int getIoPoolCapacity() { + return ioPoolCapacity; + } + + /** + * Whether direct byte buffers are preferred for the internal buffer pool. + * + * @return {@code true} for direct buffers, {@code false} for heap buffers + * @since 5.7 + */ + public boolean isDirectBuffers() { + return directBuffers; + } + + /** + * Whether the client automatically responds to PING frames with a PONG frame. + * + * @return {@code true} if auto-PONG is enabled + * @since 5.7 + */ + public boolean isAutoPong() { + return autoPong; + } + + /** + * Socket timeout used while waiting for the peer to complete the close handshake. + * + * @return close wait timeout (never {@code null}) + * @since 5.7 + */ + public Timeout getCloseWaitTimeout() { + return closeWaitTimeout; + } + + /** + * Maximum accepted message size after fragment reassembly (and after decompression if enabled). + * + * @return maximum message size in bytes (must be > 0) + * @since 5.7 + */ + public long getMaxMessageSize() { + return maxMessageSize; + } + + /** + * Maximum number of queued outbound control frames. + * + *

This bounds memory usage and prevents unbounded growth of control traffic under backpressure.

+ * + * @return maximum outbound control queue size (must be > 0) + * @since 5.7 + */ + public int getMaxOutboundControlQueue() { + return maxOutboundControlQueue; + } + + /** + * Returns {@code true} if HTTP/2 Extended CONNECT (RFC 8441) is enabled. + * + * @since 5.7 + */ + public boolean isHttp2Enabled() { + return http2Enabled; + } + + /** + * Creates a new builder instance with default settings. + * + * @return builder + * @since 5.7 + */ + public static Builder custom() { + return new Builder(); + } + + /** + * Builder for {@link WebSocketClientConfig}. + * + *

The builder is mutable and not thread-safe.

+ * + * @since 5.7 + */ + public static final class Builder { + + private Timeout connectTimeout = Timeout.ofSeconds(10); + private List subprotocols = new ArrayList<>(); + + private boolean perMessageDeflateEnabled = true; + private boolean offerServerNoContextTakeover = true; + private boolean offerClientNoContextTakeover = true; + private Integer offerClientMaxWindowBits = 15; + private Integer offerServerMaxWindowBits = null; + + private int maxFrameSize = 64 * 1024; + private int outgoingChunkSize = 8 * 1024; + private int maxFramesPerTick = 1024; + + private int ioPoolCapacity = 64; + private boolean directBuffers = true; + + private boolean autoPong = true; + private Timeout closeWaitTimeout = Timeout.ofSeconds(10); + private long maxMessageSize = 8L * 1024 * 1024; + + private int maxOutboundControlQueue = 256; + private boolean http2Enabled; + + /** + * Sets the timeout used to establish the initial TCP/TLS connection. + * + * @param v timeout, may be {@code null} to rely on defaults + * @return this builder + * @since 5.7 + */ + public Builder setConnectTimeout(final Timeout v) { + this.connectTimeout = v; + return this; + } + + /** + * Sets the ordered list of subprotocols offered to the server. + * + * @param v list of subprotocol names, may be {@code null} to offer none + * @return this builder + * @since 5.7 + */ + public Builder setSubprotocols(final List v) { + this.subprotocols = v; + return this; + } + + /** + * Enables or disables offering {@code permessage-deflate} during the handshake. + * + * @param v {@code true} to offer PMCE, {@code false} otherwise + * @return this builder + * @since 5.7 + */ + public Builder enablePerMessageDeflate(final boolean v) { + this.perMessageDeflateEnabled = v; + return this; + } + + /** + * Offers {@code server_no_context_takeover} in the PMCE offer. + * + * @param v whether to include the parameter in the offer + * @return this builder + * @since 5.7 + */ + public Builder offerServerNoContextTakeover(final boolean v) { + this.offerServerNoContextTakeover = v; + return this; + } + + /** + * Offers {@code client_no_context_takeover} in the PMCE offer. + * + * @param v whether to include the parameter in the offer + * @return this builder + * @since 5.7 + */ + public Builder offerClientNoContextTakeover(final boolean v) { + this.offerClientNoContextTakeover = v; + return this; + } + + /** + * Offers {@code client_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. The client encoder + * currently supports only {@code 15} due to JDK Deflater limitations.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.7 + */ + public Builder offerClientMaxWindowBits(final Integer v) { + this.offerClientMaxWindowBits = v; + return this; + } + + /** + * Offers {@code server_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. This value limits the + * server's compressor; the client decoder can accept any 8..15 value.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.7 + */ + public Builder offerServerMaxWindowBits(final Integer v) { + this.offerServerMaxWindowBits = v; + return this; + } + + /** + * Sets the maximum accepted frame payload size. + * + * @param v maximum frame payload size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxFrameSize(final int v) { + this.maxFrameSize = v; + return this; + } + + /** + * Sets the preferred outgoing fragmentation chunk size. + * + * @param v chunk size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setOutgoingChunkSize(final int v) { + this.outgoingChunkSize = v; + return this; + } + + /** + * Sets the limit of frames written per reactor tick. + * + * @param v max frames per tick (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxFramesPerTick(final int v) { + this.maxFramesPerTick = v; + return this; + } + + /** + * Sets the capacity of the internal buffer pool. + * + * @param v pool capacity (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setIoPoolCapacity(final int v) { + this.ioPoolCapacity = v; + return this; + } + + /** + * Enables or disables the use of direct buffers for the internal pool. + * + * @param v {@code true} for direct buffers, {@code false} for heap buffers + * @return this builder + * @since 5.7 + */ + public Builder setDirectBuffers(final boolean v) { + this.directBuffers = v; + return this; + } + + /** + * Enables or disables automatic PONG replies for received PING frames. + * + * @param v {@code true} to auto-reply with PONG + * @return this builder + * @since 5.7 + */ + public Builder setAutoPong(final boolean v) { + this.autoPong = v; + return this; + } + + /** + * Sets the close handshake wait timeout. + * + * @param v close wait timeout, must not be {@code null} + * @return this builder + * @since 5.7 + */ + public Builder setCloseWaitTimeout(final Timeout v) { + this.closeWaitTimeout = v; + return this; + } + + /** + * Sets the maximum accepted message size. + * + * @param v max message size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxMessageSize(final long v) { + this.maxMessageSize = v; + return this; + } + + /** + * Sets the maximum number of queued outbound control frames. + * + * @param v max control queue size (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxOutboundControlQueue(final int v) { + this.maxOutboundControlQueue = v; + return this; + } + + /** + * Enables HTTP/2 Extended CONNECT (RFC 8441) for supported endpoints. + * + * @param enabled true to enable HTTP/2 WebSocket connections + * @return this builder + * @since 5.7 + */ + public Builder enableHttp2(final boolean enabled) { + this.http2Enabled = enabled; + return this; + } + + /** + * Builds an immutable {@link WebSocketClientConfig}. + * + * @return configuration instance + * @throws IllegalArgumentException if any parameter is invalid + * @since 5.7 + */ + public WebSocketClientConfig build() { + if (offerClientMaxWindowBits != null && (offerClientMaxWindowBits < 8 || offerClientMaxWindowBits > 15)) { + throw new IllegalArgumentException("offerClientMaxWindowBits must be in range [8..15]"); + } + if (offerServerMaxWindowBits != null && (offerServerMaxWindowBits < 8 || offerServerMaxWindowBits > 15)) { + throw new IllegalArgumentException("offerServerMaxWindowBits must be in range [8..15]"); + } + if (closeWaitTimeout == null) { + throw new IllegalArgumentException("closeWaitTimeout != null"); + } + if (maxFrameSize <= 0) { + throw new IllegalArgumentException("maxFrameSize > 0"); + } + if (outgoingChunkSize <= 0) { + throw new IllegalArgumentException("outgoingChunkSize > 0"); + } + if (maxFramesPerTick <= 0) { + throw new IllegalArgumentException("maxFramesPerTick > 0"); + } + if (ioPoolCapacity <= 0) { + throw new IllegalArgumentException("ioPoolCapacity > 0"); + } + if (maxMessageSize <= 0) { + throw new IllegalArgumentException("maxMessageSize > 0"); + } + if (maxOutboundControlQueue <= 0) { + throw new IllegalArgumentException("maxOutboundControlQueue > 0"); + } + return new WebSocketClientConfig( + connectTimeout, subprotocols, + perMessageDeflateEnabled, offerServerNoContextTakeover, offerClientNoContextTakeover, + offerClientMaxWindowBits, offerServerMaxWindowBits, + maxFrameSize, outgoingChunkSize, maxFramesPerTick, + ioPoolCapacity, directBuffers, + autoPong, closeWaitTimeout, maxMessageSize, + maxOutboundControlQueue, + http2Enabled + ); + } + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java new file mode 100644 index 0000000000..c2ad5ca393 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java @@ -0,0 +1,103 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; + +/** + * Callback interface for receiving WebSocket events. + * + *

Implementations should be fast and non-blocking because callbacks + * are normally invoked on I/O dispatcher threads.

+ * + *

Exceptions thrown by callbacks are treated as errors and may result + * in the connection closing. Implementations should handle their own + * failures and avoid throwing unless they intend to abort the session.

+ * + * @since 5.7 + */ +public interface WebSocketListener { + + /** + * Invoked when the WebSocket connection has been established. + */ + default void onOpen(WebSocket webSocket) { + } + + /** + * Invoked when a complete text message has been received. + * + * @param data characters of the message; the buffer is only valid + * for the duration of the callback. + * @param last always {@code true} for now; reserved for future + * streaming support. + */ + default void onText(CharBuffer data, boolean last) { + } + + /** + * Invoked when a complete binary message has been received. + * + * @param data binary payload; the buffer is only valid for the + * duration of the callback. + * @param last always {@code true} for now; reserved for future + * streaming support. + */ + default void onBinary(ByteBuffer data, boolean last) { + } + + /** + * Invoked when a PING control frame is received. + */ + default void onPing(ByteBuffer data) { + } + + /** + * Invoked when a PONG control frame is received. + */ + default void onPong(ByteBuffer data) { + } + + /** + * Invoked when the WebSocket has been closed. + * + * @param statusCode close status code. + * @param reason close reason, never {@code null} but may be empty. + */ + default void onClose(int statusCode, String reason) { + } + + /** + * Invoked when a fatal error occurs on the WebSocket connection. + * + *

After this callback the connection is considered closed.

+ */ + default void onError(Throwable cause) { + } +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java new file mode 100644 index 0000000000..aefc2615b5 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Public WebSocket API for client applications. + * + *

Types in this package are stable and intended for direct use: + * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.api; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java new file mode 100644 index 0000000000..622f951238 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java @@ -0,0 +1,123 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.io.ModalCloseable; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +/** + * Public WebSocket client API mirroring {@code CloseableHttpAsyncClient}'s shape. + * + *

Subclasses provide the actual connect implementation in + * {@link #doConnect(URI, WebSocketListener, WebSocketClientConfig, HttpContext)}. + * Overloads of {@code connect(...)} funnel into that single method.

+ * + *

This type is a {@link ModalCloseable}; use {@link #close(CloseMode)} to select + * graceful or immediate shutdown. A graceful close allows in-flight I/O to finish, + * while immediate close aborts active operations.

+ * + * @since 5.7 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public abstract class CloseableWebSocketClient implements WebSocketClient, ModalCloseable { + + /** + * Start underlying I/O. Safe to call once; subsequent calls are no-ops. + */ + public abstract void start(); + + /** + * Current I/O reactor status. + */ + public abstract IOReactorStatus getStatus(); + + /** + * Best-effort await of shutdown. + */ + public abstract void awaitShutdown(TimeValue waitTime) throws InterruptedException; + + /** + * Initiate shutdown (non-blocking). + */ + public abstract void initiateShutdown(); + + /** + * Core connect hook for subclasses. + * + * @param uri target WebSocket URI (ws:// or wss://) + * @param listener application callbacks + * @param cfg optional per-connection config (may be {@code null} for defaults) + * @param context optional HTTP context (may be {@code null}) + */ + protected abstract CompletableFuture doConnect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); + + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return connect(uri, listener, null, HttpCoreContext.create()); + } + + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return connect(uri, listener, cfg, HttpCoreContext.create()); + } + + @Override + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return doConnect(uri, listener, cfg, context); + } + +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java new file mode 100644 index 0000000000..cbe660179a --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java @@ -0,0 +1,77 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Client for establishing WebSocket connections using the underlying + * asynchronous HttpClient infrastructure. + * + *

This interface represents the minimal contract for initiating + * WebSocket handshakes. Implementations are expected to be thread-safe.

+ * + * @since 5.7 + */ +public interface WebSocketClient { + + /** + * Initiates an asynchronous WebSocket connection to the given target URI. + * + *

The URI must use the {@code ws} or {@code wss} scheme. This method + * performs an HTTP/1.1 upgrade to the WebSocket protocol and, on success, + * creates a {@link WebSocket} associated with the supplied + * {@link WebSocketListener}.

+ * + *

The operation is fully asynchronous. The returned + * {@link CompletableFuture} completes when the opening WebSocket + * handshake has either succeeded or failed.

+ * + * @param uri target WebSocket URI, must not be {@code null}. + * @param listener callback that receives WebSocket events, must not be {@code null}. + * @param cfg optional per-connection configuration; if {@code null}, the + * client’s default configuration is used. + * @param context optional HTTP context for the underlying upgrade request; + * may be {@code null}. + * @return a future that completes with a connected {@link WebSocket} on + * success, or completes exceptionally if the connection attempt + * or protocol handshake fails. + * @since 5.7 + */ + CompletableFuture connect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); + +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java new file mode 100644 index 0000000000..8927bb32bb --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java @@ -0,0 +1,467 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.client.impl.DefaultWebSocketClient; +import org.apache.hc.client5.http.websocket.client.impl.logging.WsLoggingExceptionCallback; +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.function.Decorator; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.impl.nio.ClientHttp1IOEventHandlerFactory; +import org.apache.hc.core5.http.impl.nio.ClientHttp1StreamDuplexerFactory; +import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequesterBootstrap; +import org.apache.hc.core5.http2.impl.H2Processors; +import org.apache.hc.core5.pool.ConnPoolListener; +import org.apache.hc.core5.pool.DefaultDisposalCallback; +import org.apache.hc.core5.pool.LaxConnPool; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; +import org.apache.hc.core5.pool.PoolReusePolicy; +import org.apache.hc.core5.pool.StrictConnPool; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.IOReactorMetricsListener; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.IOSessionListener; +import org.apache.hc.core5.reactor.IOWorkerSelector; +import org.apache.hc.core5.util.Timeout; + +/** + * Builder for {@link CloseableWebSocketClient} instances. + * + *

This builder assembles a WebSocket client on top of the asynchronous + * HTTP/1.1 requester and connection pool infrastructure provided by + * HttpComponents Core. Unless otherwise specified, sensible defaults + * are used for all components.

+ * + *

The resulting {@link CloseableWebSocketClient} manages its own I/O + * reactor and connection pool and must be {@link java.io.Closeable#close() + * closed} when no longer needed.

+ * + *

Builders are mutable and not thread-safe. Configure the instance + * on a single thread and then call {@link #build()}.

+ * + * @since 5.7 + */ +public final class WebSocketClientBuilder { + + private IOReactorConfig ioReactorConfig; + private Http1Config http1Config; + private CharCodingConfig charCodingConfig; + private HttpProcessor httpProcessor; + private ConnectionReuseStrategy connStrategy; + private int defaultMaxPerRoute; + private int maxTotal; + private Timeout timeToLive; + private PoolReusePolicy poolReusePolicy; + private PoolConcurrencyPolicy poolConcurrencyPolicy; + private TlsStrategy tlsStrategy; + private Timeout handshakeTimeout; + private Decorator ioSessionDecorator; + private Callback exceptionCallback; + private IOSessionListener sessionListener; + private Http1StreamListener streamListener; + private H2Config h2Config; + private ConnPoolListener connPoolListener; + private ThreadFactory threadFactory; + + // Optional listeners for reactor metrics and worker selection. + private IOReactorMetricsListener reactorMetricsListener; + private IOWorkerSelector workerSelector; + + private WebSocketClientConfig defaultConfig = WebSocketClientConfig.custom().build(); + + private WebSocketClientBuilder() { + } + + /** + * Creates a new {@code WebSocketClientBuilder} instance. + * + * @return a new builder. + */ + public static WebSocketClientBuilder create() { + return new WebSocketClientBuilder(); + } + + /** + * Sets the default configuration applied to WebSocket connections + * created by the resulting client. + * + * @param defaultConfig default WebSocket configuration; if {@code null} + * the existing default is retained. + * @return this builder. + */ + public WebSocketClientBuilder defaultConfig(final WebSocketClientConfig defaultConfig) { + if (defaultConfig != null) { + this.defaultConfig = defaultConfig; + } + return this; + } + + /** + * Sets the I/O reactor configuration. + * + * @param ioReactorConfig I/O reactor configuration, or {@code null} + * to use {@link IOReactorConfig#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setIOReactorConfig(final IOReactorConfig ioReactorConfig) { + this.ioReactorConfig = ioReactorConfig; + return this; + } + + /** + * Sets the HTTP/1.1 configuration for the underlying requester. + * + * @param http1Config HTTP/1.1 configuration, or {@code null} + * to use {@link Http1Config#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config; + return this; + } + + /** + * Sets the character coding configuration for HTTP message processing. + * + * @param charCodingConfig character coding configuration, or {@code null} + * to use {@link CharCodingConfig#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + /** + * Sets a custom {@link HttpProcessor} for HTTP/1.1 requests. + * + * @param httpProcessor HTTP processor, or {@code null} to use + * {@link HttpProcessors#client()}. + * @return this builder. + */ + public WebSocketClientBuilder setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + /** + * Sets the connection reuse strategy for persistent HTTP connections. + * + * @param connStrategy connection reuse strategy, or {@code null} + * to use {@link DefaultClientConnectionReuseStrategy}. + * @return this builder. + */ + public WebSocketClientBuilder setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) { + this.connStrategy = connStrategy; + return this; + } + + /** + * Sets the default maximum number of connections per route. + * + * @param defaultMaxPerRoute maximum connections per route; values + * ≤ 0 cause the default of {@code 20} + * to be used. + * @return this builder. + */ + public WebSocketClientBuilder setDefaultMaxPerRoute(final int defaultMaxPerRoute) { + this.defaultMaxPerRoute = defaultMaxPerRoute; + return this; + } + + /** + * Sets the maximum total number of connections in the pool. + * + * @param maxTotal maximum total connections; values ≤ 0 cause + * the default of {@code 50} to be used. + * @return this builder. + */ + public WebSocketClientBuilder setMaxTotal(final int maxTotal) { + this.maxTotal = maxTotal; + return this; + } + + /** + * Sets the time-to-live for persistent connections in the pool. + * + * @param timeToLive connection time-to-live, or {@code null} to use + * {@link Timeout#DISABLED}. + * @return this builder. + */ + public WebSocketClientBuilder setTimeToLive(final Timeout timeToLive) { + this.timeToLive = timeToLive; + return this; + } + + /** + * Sets the reuse policy for connections in the pool. + * + * @param poolReusePolicy reuse policy, or {@code null} to use + * {@link PoolReusePolicy#LIFO}. + * @return this builder. + */ + public WebSocketClientBuilder setPoolReusePolicy(final PoolReusePolicy poolReusePolicy) { + this.poolReusePolicy = poolReusePolicy; + return this; + } + + /** + * Sets the concurrency policy for the connection pool. + * + * @param poolConcurrencyPolicy concurrency policy, or {@code null} + * to use {@link PoolConcurrencyPolicy#STRICT}. + * @return this builder. + */ + public WebSocketClientBuilder setPoolConcurrencyPolicy(final PoolConcurrencyPolicy poolConcurrencyPolicy) { + this.poolConcurrencyPolicy = poolConcurrencyPolicy; + return this; + } + + /** + * Sets the TLS strategy used to establish HTTPS or WSS connections. + * + * @param tlsStrategy TLS strategy, or {@code null} to use + * {@link BasicClientTlsStrategy}. + * @return this builder. + */ + public WebSocketClientBuilder setTlsStrategy(final TlsStrategy tlsStrategy) { + this.tlsStrategy = tlsStrategy; + return this; + } + + /** + * Sets the timeout for the TLS handshake. + * + * @param handshakeTimeout handshake timeout, or {@code null} for no + * specific timeout. + * @return this builder. + */ + public WebSocketClientBuilder setTlsHandshakeTimeout(final Timeout handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + return this; + } + + /** + * Sets a decorator for low-level I/O sessions created by the reactor. + * + * @param ioSessionDecorator decorator, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setIOSessionDecorator(final Decorator ioSessionDecorator) { + this.ioSessionDecorator = ioSessionDecorator; + return this; + } + + /** + * Sets a callback to be notified of fatal I/O exceptions. + * + * @param exceptionCallback exception callback, or {@code null} to use + * {@link WsLoggingExceptionCallback#INSTANCE}. + * @return this builder. + */ + public WebSocketClientBuilder setExceptionCallback(final Callback exceptionCallback) { + this.exceptionCallback = exceptionCallback; + return this; + } + + /** + * Sets a listener for I/O session lifecycle events. + * + * @param sessionListener session listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setIOSessionListener(final IOSessionListener sessionListener) { + this.sessionListener = sessionListener; + return this; + } + + /** + * Sets a listener for HTTP/1.1 stream events. + * + * @param streamListener stream listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setStreamListener( + final Http1StreamListener streamListener) { + this.streamListener = streamListener; + return this; + } + + /** + * Sets a listener for connection pool events. + * + * @param connPoolListener pool listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setConnPoolListener(final ConnPoolListener connPoolListener) { + this.connPoolListener = connPoolListener; + return this; + } + + /** + * Sets the thread factory used to create the main I/O reactor thread. + * + * @param threadFactory thread factory, or {@code null} to use a + * {@link DefaultThreadFactory} named + * {@code "websocket-main"}. + * @return this builder. + */ + public WebSocketClientBuilder setThreadFactory(final ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + + /** + * Sets a metrics listener for the I/O reactor. + * + * @param reactorMetricsListener metrics listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setReactorMetricsListener( + final IOReactorMetricsListener reactorMetricsListener) { + this.reactorMetricsListener = reactorMetricsListener; + return this; + } + + /** + * Sets a worker selector for assigning I/O sessions to worker threads. + * + * @param workerSelector worker selector, or {@code null} for the default + * strategy. + * @return this builder. + */ + public WebSocketClientBuilder setWorkerSelector(final IOWorkerSelector workerSelector) { + this.workerSelector = workerSelector; + return this; + } + + /** + * Builds a new {@link CloseableWebSocketClient} instance using the + * current builder configuration. + * + *

The returned client owns its underlying I/O reactor and connection + * pool and must be closed to release system resources.

+ * + * @return a newly created {@link CloseableWebSocketClient}. + */ + public CloseableWebSocketClient build() { + + final PoolConcurrencyPolicy conc = poolConcurrencyPolicy != null + ? poolConcurrencyPolicy + : PoolConcurrencyPolicy.STRICT; + final PoolReusePolicy reuse = poolReusePolicy != null + ? poolReusePolicy + : PoolReusePolicy.LIFO; + final Timeout ttl = timeToLive != null ? timeToLive : Timeout.DISABLED; + + final ManagedConnPool connPool; + if (conc == PoolConcurrencyPolicy.LAX) { + connPool = new LaxConnPool<>( + defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20, + ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener); + } else { + connPool = new StrictConnPool<>( + defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20, + maxTotal > 0 ? maxTotal : 50, + ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener); + } + + final HttpProcessor proc = httpProcessor != null ? httpProcessor : HttpProcessors.client(); + final Http1Config h1 = http1Config != null ? http1Config : Http1Config.DEFAULT; + final CharCodingConfig coding = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; + + final ConnectionReuseStrategy reuseStrategyCopy = connStrategy != null + ? connStrategy + : new DefaultClientConnectionReuseStrategy(); + + final ClientHttp1StreamDuplexerFactory duplexerFactory = + new ClientHttp1StreamDuplexerFactory( + proc, h1, coding, reuseStrategyCopy, null, null, streamListener); + + final TlsStrategy tls = tlsStrategy != null ? tlsStrategy : new BasicClientTlsStrategy(); + final IOEventHandlerFactory iohFactory = + new ClientHttp1IOEventHandlerFactory(duplexerFactory, tls, handshakeTimeout); + + final IOReactorMetricsListener metricsListener = reactorMetricsListener != null ? reactorMetricsListener : null; + final IOWorkerSelector selector = workerSelector != null ? workerSelector : null; + + final HttpAsyncRequester requester = new HttpAsyncRequester( + ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT, + iohFactory, + ioSessionDecorator, + exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE, + sessionListener, + connPool, + tls, + handshakeTimeout, + metricsListener, + selector, + 0 + ); + + final H2MultiplexingRequester h2Requester = H2MultiplexingRequesterBootstrap.bootstrap() + .setIOReactorConfig(ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT) + .setHttpProcessor(httpProcessor != null ? httpProcessor : H2Processors.client()) + .setH2Config(h2Config != null ? h2Config : H2Config.DEFAULT) + .setTlsStrategy(tls) + .setIOSessionDecorator(ioSessionDecorator) + .setExceptionCallback(exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE) + .setIOSessionListener(sessionListener) + .setIOReactorMetricsListener(metricsListener) + .create(); + + final ThreadFactory tf = threadFactory != null + ? threadFactory + : new DefaultThreadFactory("websocket-main", true); + + return new DefaultWebSocketClient( + requester, + connPool, + defaultConfig, + tf, + h2Requester + ); + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java new file mode 100644 index 0000000000..e7bcb91b7b --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java @@ -0,0 +1,81 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; + +/** + * Static factory methods for {@link CloseableWebSocketClient} instances. + * + *

This is a convenience entry point for typical client creation + * scenarios. For advanced configuration use + * {@link WebSocketClientBuilder} directly.

+ * + *

Clients created by these helpers own their I/O resources and must be + * closed when no longer needed.

+ * + * @since 5.7 + */ +public final class WebSocketClients { + + private WebSocketClients() { + } + + /** + * Creates a new {@link WebSocketClientBuilder} instance for + * custom client configuration. + * + * @return a new {@link WebSocketClientBuilder}. + */ + public static WebSocketClientBuilder custom() { + return WebSocketClientBuilder.create(); + } + + /** + * Creates a {@link CloseableWebSocketClient} instance with + * default configuration. + * + * @return a newly created {@link CloseableWebSocketClient} + * using default settings. + */ + public static CloseableWebSocketClient createDefault() { + return custom().build(); + } + + /** + * Creates a {@link CloseableWebSocketClient} instance using + * the given default WebSocket configuration. + * + * @param defaultConfig default configuration applied to + * WebSocket connections created by + * the client; must not be {@code null}. + * @return a newly created {@link CloseableWebSocketClient}. + */ + public static CloseableWebSocketClient createWith(final WebSocketClientConfig defaultConfig) { + return custom().defaultConfig(defaultConfig).build(); + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java new file mode 100644 index 0000000000..ecab82b8b4 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java @@ -0,0 +1,125 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.core5.http.impl.bootstrap.AsyncRequester; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractWebSocketClient extends CloseableWebSocketClient { + + enum Status { READY, RUNNING, TERMINATED } + + private static final Logger LOG = LoggerFactory.getLogger(AbstractWebSocketClient.class); + + private final AsyncRequester primaryRequester; + private final AsyncRequester[] extraRequesters; + private final ExecutorService executorService; + private final AtomicReference status; + + AbstractWebSocketClient(final HttpAsyncRequester requester, final ThreadFactory threadFactory, final AsyncRequester... extraRequesters) { + super(); + this.primaryRequester = Args.notNull(requester, "requester"); + this.extraRequesters = extraRequesters != null ? extraRequesters : new AsyncRequester[0]; + this.executorService = Executors.newSingleThreadExecutor(threadFactory); + this.status = new AtomicReference<>(Status.READY); + } + + @Override + public final void start() { + if (status.compareAndSet(Status.READY, Status.RUNNING)) { + executorService.execute(() -> { + primaryRequester.start(); + for (final AsyncRequester requester : extraRequesters) { + requester.start(); + } + }); + } + } + + boolean isRunning() { + return status.get() == Status.RUNNING; + } + + @Override + public final IOReactorStatus getStatus() { + return primaryRequester.getStatus(); + } + + @Override + public final void awaitShutdown(final TimeValue waitTime) throws InterruptedException { + primaryRequester.awaitShutdown(waitTime); + for (final AsyncRequester requester : extraRequesters) { + requester.awaitShutdown(waitTime); + } + } + + @Override + public final void initiateShutdown() { + if (LOG.isDebugEnabled()) { + LOG.debug("Initiating shutdown"); + } + primaryRequester.initiateShutdown(); + for (final AsyncRequester requester : extraRequesters) { + requester.initiateShutdown(); + } + } + + void internalClose(final CloseMode closeMode) { + } + + @Override + public final void close(final CloseMode closeMode) { + if (LOG.isDebugEnabled()) { + LOG.debug("Shutdown {}", closeMode); + } + primaryRequester.initiateShutdown(); + primaryRequester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE); + for (final AsyncRequester requester : extraRequesters) { + requester.initiateShutdown(); + requester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE); + } + executorService.shutdownNow(); + internalClose(closeMode); + } + + @Override + public void close() { + close(CloseMode.GRACEFUL); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java new file mode 100644 index 0000000000..b1d94ba1d4 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java @@ -0,0 +1,53 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; + +@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL) +@Internal +public class DefaultWebSocketClient extends InternalWebSocketClientBase { + + public DefaultWebSocketClient( + final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketClientConfig defaultConfig, + final ThreadFactory threadFactory, + final H2MultiplexingRequester h2Requester) { + super(requester, connPool, defaultConfig, threadFactory, h2Requester); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java new file mode 100644 index 0000000000..cf0e2bf504 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java @@ -0,0 +1,119 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.impl.protocol.Http1UpgradeProtocol; +import org.apache.hc.client5.http.websocket.client.impl.protocol.Http2ExtendedConnectProtocol; +import org.apache.hc.client5.http.websocket.client.impl.protocol.WebSocketProtocolStrategy; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Args; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Minimal internal WS client: owns requester + pool, no extra closeables. + */ +@Internal +abstract class InternalWebSocketClientBase extends AbstractWebSocketClient { + + private static final Logger LOG = LoggerFactory.getLogger(InternalWebSocketClientBase.class); + + private final WebSocketClientConfig defaultConfig; + private final ManagedConnPool connPool; + + private final WebSocketProtocolStrategy h1; + private final WebSocketProtocolStrategy h2; + + InternalWebSocketClientBase( + final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketClientConfig defaultConfig, + final ThreadFactory threadFactory, + final H2MultiplexingRequester h2Requester) { + super(Args.notNull(requester, "requester"), threadFactory, h2Requester); + this.connPool = Args.notNull(connPool, "connPool"); + this.defaultConfig = defaultConfig != null ? defaultConfig : WebSocketClientConfig.custom().build(); + this.h1 = newH1Protocol(requester, connPool); + this.h2 = newH2Protocol(h2Requester); + } + + /** + * HTTP/1.1 Upgrade protocol. + */ + protected WebSocketProtocolStrategy newH1Protocol( + final HttpAsyncRequester requester, + final ManagedConnPool connPool) { + return new Http1UpgradeProtocol(requester, connPool); + } + + /** + * HTTP/2 Extended CONNECT protocol. + */ + protected WebSocketProtocolStrategy newH2Protocol(final H2MultiplexingRequester requester) { + return requester != null ? new Http2ExtendedConnectProtocol(requester) : null; + } + + @Override + protected CompletableFuture doConnect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfgOrNull, + final HttpContext context) { + + final WebSocketClientConfig cfg = cfgOrNull != null ? cfgOrNull : defaultConfig; + if (cfg.isHttp2Enabled() && h2 != null) { + return h2.connect(uri, listener, cfg, context); + } + return h1.connect(uri, listener, cfg, context); + } + + @Override + protected void internalClose(final CloseMode closeMode) { + try { + final CloseMode mode = closeMode != null ? closeMode : CloseMode.GRACEFUL; + connPool.close(mode); + } catch (final Exception ex) { + if (LOG.isWarnEnabled()) { + LOG.warn("Error closing pool: {}", ex.getMessage(), ex); + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java new file mode 100644 index 0000000000..635d81ca6e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java @@ -0,0 +1,215 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.connector; + +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.ComplexFuture; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.nio.command.RequestExecutionCommand; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolEntry; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.EndpointParameters; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; + +/** + * Facade that leases an IOSession from the pool and exposes a ProtocolIOSession through AsyncClientEndpoint. + * + * @since 5.7 + */ +@Internal +public final class WebSocketEndpointConnector { + + private final HttpAsyncRequester requester; + private final ManagedConnPool connPool; + + public WebSocketEndpointConnector(final HttpAsyncRequester requester, final ManagedConnPool connPool) { + this.requester = Args.notNull(requester, "requester"); + this.connPool = Args.notNull(connPool, "connPool"); + } + + public final class ProtoEndpoint extends AsyncClientEndpoint { + + private final AtomicReference> poolEntryRef; + + ProtoEndpoint(final PoolEntry poolEntry) { + this.poolEntryRef = new AtomicReference<>(poolEntry); + } + + private PoolEntry getPoolEntryOrThrow() { + final PoolEntry pe = poolEntryRef.get(); + if (pe == null) { + throw new IllegalStateException("Endpoint has already been released"); + } + return pe; + } + + private IOSession getIOSessionOrThrow() { + final IOSession io = getPoolEntryOrThrow().getConnection(); + if (io == null) { + throw new IllegalStateException("I/O session is invalid"); + } + return io; + } + + /** + * Expose the ProtocolIOSession for protocol switching. + */ + public ProtocolIOSession getProtocolIOSession() { + final IOSession io = getIOSessionOrThrow(); + if (!(io instanceof ProtocolIOSession)) { + throw new IllegalStateException("Underlying IOSession is not a ProtocolIOSession: " + io); + } + return (ProtocolIOSession) io; + } + + @Override + public void execute(final AsyncClientExchangeHandler exchangeHandler, + final HandlerFactory pushHandlerFactory, + final HttpContext context) { + Args.notNull(exchangeHandler, "Exchange handler"); + final IOSession ioSession = getIOSessionOrThrow(); + ioSession.enqueue(new RequestExecutionCommand(exchangeHandler, pushHandlerFactory, null, context), Command.Priority.NORMAL); + if (!ioSession.isOpen()) { + try { + exchangeHandler.failed(new ConnectionClosedException()); + } finally { + exchangeHandler.releaseResources(); + } + } + } + + @Override + public boolean isConnected() { + final PoolEntry pe = poolEntryRef.get(); + final IOSession io = pe != null ? pe.getConnection() : null; + return io != null && io.isOpen(); + } + + @Override + public void releaseAndReuse() { + final PoolEntry pe = poolEntryRef.getAndSet(null); + if (pe != null) { + final IOSession io = pe.getConnection(); + connPool.release(pe, io != null && io.isOpen()); + } + } + + @Override + public void releaseAndDiscard() { + final PoolEntry pe = poolEntryRef.getAndSet(null); + if (pe != null) { + pe.discardConnection(CloseMode.GRACEFUL); + connPool.release(pe, false); + } + } + } + + public Future connect(final HttpHost host, + final Timeout timeout, + final Object attachment, + final FutureCallback callback) { + Args.notNull(host, "Host"); + Args.notNull(timeout, "Timeout"); + + final ComplexFuture resultFuture = new ComplexFuture<>(callback); + + final Future> leaseFuture = connPool.lease(host, null, timeout, + new FutureCallback>() { + @Override + public void completed(final PoolEntry poolEntry) { + final ProtoEndpoint endpoint = new ProtoEndpoint(poolEntry); + final IOSession ioSession = poolEntry.getConnection(); + if (ioSession != null && !ioSession.isOpen()) { + poolEntry.discardConnection(CloseMode.IMMEDIATE); + } + if (poolEntry.hasConnection()) { + resultFuture.completed(endpoint); + } else { + final Future future = requester.requestSession( + host, timeout, + new EndpointParameters(host, attachment), + new FutureCallback() { + @Override + public void completed(final IOSession session) { + session.setSocketTimeout(timeout); + poolEntry.assignConnection(session); + resultFuture.completed(endpoint); + } + + @Override + public void failed(final Exception cause) { + try { + resultFuture.failed(cause); + } finally { + endpoint.releaseAndDiscard(); + } + } + + @Override + public void cancelled() { + try { + resultFuture.cancel(); + } finally { + endpoint.releaseAndDiscard(); + } + } + }); + resultFuture.setDependency(future); + } + } + + @Override + public void failed(final Exception ex) { + resultFuture.failed(ex); + } + + @Override + public void cancelled() { + resultFuture.cancel(); + } + }); + + resultFuture.setDependency(leaseFuture); + return resultFuture; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java new file mode 100644 index 0000000000..11bfe7dfc2 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.connector; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java new file mode 100644 index 0000000000..325440139c --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java @@ -0,0 +1,58 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.logging; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Internal +public class WsLoggingExceptionCallback implements Callback { + + /** + * Singleton instance of LoggingExceptionCallback. + */ + public static final WsLoggingExceptionCallback INSTANCE = new WsLoggingExceptionCallback(); + + private static final Logger LOG = LoggerFactory.getLogger("org.apache.hc.client5.http.websocket.client"); + + private WsLoggingExceptionCallback() { + } + + @Override + public void execute(final Exception ex) { + if (ex instanceof ConnectionClosedException) { + LOG.debug(ex.getMessage(), ex); + return; + } + LOG.error(ex.getMessage(), ex); + } + +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java new file mode 100644 index 0000000000..47a68d46f5 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.logging; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java new file mode 100644 index 0000000000..c569c74cc3 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Public WebSocket API for client applications. + * + *

Types in this package are stable and intended for direct use: + * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java new file mode 100644 index 0000000000..a1a52ddfed --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java @@ -0,0 +1,500 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.List; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.impl.connector.WebSocketEndpointConnector; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.PerMessageDeflate; +import org.apache.hc.client5.http.websocket.transport.WebSocketUpgrader; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HTTP/1.1 Upgrade (RFC 6455). Uses getters on WebSocketClientConfig. + */ +@Internal +public final class Http1UpgradeProtocol implements WebSocketProtocolStrategy { + + private static final Logger LOG = LoggerFactory.getLogger(Http1UpgradeProtocol.class); + + private final HttpAsyncRequester requester; + private final ManagedConnPool connPool; + + public Http1UpgradeProtocol(final HttpAsyncRequester requester, final ManagedConnPool connPool) { + this.requester = requester; + this.connPool = connPool; + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + + Args.notNull(uri, "uri"); + Args.notNull(listener, "listener"); + Args.notNull(cfg, "cfg"); + + final boolean secure = "wss".equalsIgnoreCase(uri.getScheme()); + if (!secure && !"ws".equalsIgnoreCase(uri.getScheme())) { + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new IllegalArgumentException("Scheme must be ws or wss")); + return f; + } + + final String scheme = secure ? URIScheme.HTTPS.id : URIScheme.HTTP.id; + final int port = uri.getPort() > 0 ? uri.getPort() : secure ? 443 : 80; + final String host = Args.notBlank(uri.getHost(), "host"); + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + final String fullPath = uri.getRawQuery() != null ? path + "?" + uri.getRawQuery() : path; + final HttpHost target = new HttpHost(scheme, host, port); + + final CompletableFuture result = new CompletableFuture<>(); + final WebSocketEndpointConnector wsRequester = new WebSocketEndpointConnector(requester, connPool); + + final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10); + + wsRequester.connect(target, timeout, null, + new FutureCallback() { + @Override + public void completed(final WebSocketEndpointConnector.ProtoEndpoint endpoint) { + try { + final String secKey = randomKey(); + final BasicHttpRequest req = new BasicHttpRequest(HttpGet.METHOD_NAME, target, fullPath); + + req.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + req.addHeader(HttpHeaders.UPGRADE, "websocket"); + req.addHeader("Sec-WebSocket-Version", "13"); + req.addHeader("Sec-WebSocket-Key", secKey); + + // subprotocols + if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) { + final StringJoiner sj = new StringJoiner(", "); + for (final String p : cfg.getSubprotocols()) { + if (p != null && !p.isEmpty()) { + sj.add(p); + } + } + final String offered = sj.toString(); + if (!offered.isEmpty()) { + req.addHeader("Sec-WebSocket-Protocol", offered); + } + } + + // PMCE offer + if (cfg.isPerMessageDeflateEnabled()) { + final StringBuilder ext = new StringBuilder("permessage-deflate"); + if (cfg.isOfferServerNoContextTakeover()) { + ext.append("; server_no_context_takeover"); + } + if (cfg.isOfferClientNoContextTakeover()) { + ext.append("; client_no_context_takeover"); + } + if (cfg.getOfferClientMaxWindowBits() != null) { + ext.append("; client_max_window_bits=").append(cfg.getOfferClientMaxWindowBits()); + } + if (cfg.getOfferServerMaxWindowBits() != null) { + ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits()); + } + req.addHeader("Sec-WebSocket-Extensions", ext.toString()); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Dispatching HTTP/1.1 Upgrade: GET {} with headers:", fullPath); + for (final Header h : req.getHeaders()) { + LOG.debug(" {}: {}", h.getName(), h.getValue()); + } + } + + final AtomicBoolean done = new AtomicBoolean(false); + + final AsyncClientExchangeHandler upgrade = new AsyncClientExchangeHandler() { + @Override + public void releaseResources() { + } + + @Override + public void failed(final Exception cause) { + if (done.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(cause); + } + } + + @Override + public void cancel() { + if (done.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.cancel(true); + } + } + + @Override + public void produceRequest(final RequestChannel ch, + final HttpContext hc) + throws IOException, HttpException { + ch.sendRequest(req, null, hc); + } + + @Override + public int available() { + return 0; + } + + @Override + public void produce(final DataStreamChannel channel) { + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) { + } + + @Override + public void consume(final ByteBuffer src) { + } + + @Override + public void streamEnd(final List trailers) { + } + + @Override + public void consumeInformation(final HttpResponse response, + final HttpContext hc) { + final int code = response.getCode(); + if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) { + finishUpgrade(endpoint, response, secKey, listener, cfg, result); + } + } + + @Override + public void consumeResponse(final HttpResponse response, + final EntityDetails entity, + final HttpContext hc) { + final int code = response.getCode(); + if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) { + finishUpgrade(endpoint, response, secKey, listener, cfg, result); + return; + } + failed(new IllegalStateException("Unexpected status: " + code)); + } + }; + + endpoint.execute(upgrade, null, context); + + } catch (final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + } + + @Override + public void failed(final Exception ex) { + result.completeExceptionally(ex); + } + + @Override + public void cancelled() { + result.cancel(true); + } + }); + + return result; + } + + private void finishUpgrade( + final WebSocketEndpointConnector.ProtoEndpoint endpoint, + final HttpResponse response, + final String secKey, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final CompletableFuture result) { + try { + final String accept = headerValue(response, "Sec-WebSocket-Accept"); + final String expected = expectedAccept(secKey); + final String acceptValue = accept != null ? accept.trim() : null; + if (!expected.equals(acceptValue)) { + throw new IllegalStateException("Bad Sec-WebSocket-Accept"); + } + + final String upgrade = headerValue(response, "Upgrade"); + if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade.trim())) { + throw new IllegalStateException("Missing/invalid Upgrade header: " + upgrade); + } + if (!containsToken(response, "Connection", "Upgrade")) { + throw new IllegalStateException("Missing/invalid Connection header"); + } + + final String proto = headerValue(response, "Sec-WebSocket-Protocol"); + if (proto != null && !proto.isEmpty()) { + boolean matched = false; + if (cfg.getSubprotocols() != null) { + for (final String p : cfg.getSubprotocols()) { + if (p.equals(proto)) { + matched = true; + break; + } + } + } + if (!matched) { + throw new IllegalStateException("Server selected subprotocol not offered: " + proto); + } + } + + final ExtensionChain chain = buildExtensionChain(cfg, headerValue(response, "Sec-WebSocket-Extensions")); + + final ProtocolIOSession ioSession = endpoint.getProtocolIOSession(); + final WebSocketUpgrader upgrader = new WebSocketUpgrader(listener, cfg, chain, endpoint); + ioSession.registerProtocol("websocket", upgrader); + ioSession.switchProtocol("websocket", new FutureCallback() { + @Override + public void completed(final ProtocolIOSession s) { + s.setSocketTimeout(Timeout.DISABLED); + final WebSocket ws = upgrader.getWebSocket(); + try { + listener.onOpen(ws); + } catch (final Throwable ignore) { + } + result.complete(ws); + } + + @Override + public void failed(final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + + @Override + public void cancelled() { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.cancel(true); + } + }); + + } catch (final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + } + + private static String headerValue(final HttpResponse r, final String name) { + final Header h = r.getFirstHeader(name); + return h != null ? h.getValue() : null; + } + + private static boolean containsToken(final HttpResponse r, final String header, final String token) { + for (final Header h : r.getHeaders(header)) { + for (final String p : h.getValue().split(",")) { + if (p.trim().equalsIgnoreCase(token)) { + return true; + } + } + } + return false; + } + + private static String randomKey() { + final byte[] nonce = new byte[16]; + ThreadLocalRandom.current().nextBytes(nonce); + return Base64.getEncoder().encodeToString(nonce); + } + + private static String expectedAccept(final String key) throws Exception { + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII)); + return Base64.getEncoder().encodeToString(sha1.digest()); + } + + static ExtensionChain buildExtensionChain(final WebSocketClientConfig cfg, final String ext) { + final ExtensionChain chain = new ExtensionChain(); + if (ext == null || ext.isEmpty()) { + return chain; + } + boolean pmceSeen = false, serverNoCtx = false, clientNoCtx = false; + Integer clientBits = null, serverBits = null; + final boolean offerServerNoCtx = cfg.isOfferServerNoContextTakeover(); + final boolean offerClientNoCtx = cfg.isOfferClientNoContextTakeover(); + final Integer offerClientBits = cfg.getOfferClientMaxWindowBits(); + final Integer offerServerBits = cfg.getOfferServerMaxWindowBits(); + + final String[] tokens = ext.split(","); + for (final String raw0 : tokens) { + final String raw = raw0.trim(); + final String[] parts = raw.split(";"); + final String token = parts[0].trim().toLowerCase(); + + // Only permessage-deflate is supported + if (!"permessage-deflate".equals(token)) { + throw new IllegalStateException("Server selected unsupported extension: " + token); + } + if (pmceSeen) { + throw new IllegalStateException("Server selected permessage-deflate more than once"); + } + pmceSeen = true; + + for (int i = 1; i < parts.length; i++) { + final String p = parts[i].trim(); + final int eq = p.indexOf('='); + if (eq < 0) { + if ("server_no_context_takeover".equalsIgnoreCase(p)) { + if (!offerServerNoCtx) { + throw new IllegalStateException("Server selected server_no_context_takeover not offered"); + } + serverNoCtx = true; + } else if ("client_no_context_takeover".equalsIgnoreCase(p)) { + if (!offerClientNoCtx) { + throw new IllegalStateException("Server selected client_no_context_takeover not offered"); + } + clientNoCtx = true; + } else { + throw new IllegalStateException("Unsupported permessage-deflate parameter: " + p); + } + } else { + final String k = p.substring(0, eq).trim(); + String v = p.substring(eq + 1).trim(); + if (v.length() >= 2 && v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"') { + v = v.substring(1, v.length() - 1); // strip quotes if any + } + if ("client_max_window_bits".equalsIgnoreCase(k)) { + if (offerClientBits == null) { + throw new IllegalStateException("Server selected client_max_window_bits not offered"); + } + try { + if (v.isEmpty()) { + throw new IllegalStateException("client_max_window_bits must have a value"); + } + clientBits = Integer.parseInt(v); + if (clientBits < 8 || clientBits > 15) { + throw new IllegalStateException("client_max_window_bits out of range: " + clientBits); + } + } catch (final NumberFormatException nfe) { + throw new IllegalStateException("Invalid client_max_window_bits: " + v, nfe); + } + } else if ("server_max_window_bits".equalsIgnoreCase(k)) { + if (offerServerBits == null) { + throw new IllegalStateException("Server selected server_max_window_bits not offered"); + } + try { + if (v.isEmpty()) { + throw new IllegalStateException("server_max_window_bits must have a value"); + } + serverBits = Integer.parseInt(v); + if (serverBits < 8 || serverBits > 15) { + throw new IllegalStateException("server_max_window_bits out of range: " + serverBits); + } + } catch (final NumberFormatException nfe) { + throw new IllegalStateException("Invalid server_max_window_bits: " + v, nfe); + } + } else { + throw new IllegalStateException("Unsupported permessage-deflate parameter: " + k); + } + } + } + } + + if (pmceSeen) { + if (!cfg.isPerMessageDeflateEnabled()) { + throw new IllegalStateException("Server negotiated PMCE but client disabled it"); + } + if (clientBits != null) { + if (offerClientBits == null) { + throw new IllegalStateException("Server selected client_max_window_bits not offered"); + } + if (!clientBits.equals(offerClientBits)) { + throw new IllegalStateException("Unsupported client_max_window_bits: " + clientBits + + " (offered " + offerClientBits + ")"); + } + } + if (serverBits != null) { + if (offerServerBits != null && serverBits > offerServerBits) { + throw new IllegalStateException("server_max_window_bits exceeds offer: " + serverBits); + } + } + chain.add(new PerMessageDeflate(true, serverNoCtx, clientNoCtx, clientBits, serverBits)); + } + return chain; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java new file mode 100644 index 0000000000..d78f4a5672 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java @@ -0,0 +1,624 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayDeque; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.transport.WebSocketFrameDecoder; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.ProtocolException; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http2.H2PseudoRequestHeaders; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter; +import org.apache.hc.core5.websocket.message.CloseCodec; + +/** + * RFC 8441 (HTTP/2 Extended CONNECT) placeholder. + * No-args ctor (matches your build error). Falls back to H1. + */ +@Internal +public final class Http2ExtendedConnectProtocol implements WebSocketProtocolStrategy { + + public static final class H2NotAvailable extends RuntimeException { + public H2NotAvailable(final String msg) { + super(msg); + } + } + + private final H2MultiplexingRequester requester; + + public Http2ExtendedConnectProtocol(final H2MultiplexingRequester requester) { + this.requester = requester; + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + final CompletableFuture f = new CompletableFuture<>(); + if (requester == null) { + f.completeExceptionally(new H2NotAvailable("HTTP/2 requester not configured")); + return f; + } + Args.notNull(uri, "uri"); + Args.notNull(listener, "listener"); + Args.notNull(cfg, "cfg"); + + final boolean secure = "wss".equalsIgnoreCase(uri.getScheme()); + if (!secure && !"ws".equalsIgnoreCase(uri.getScheme())) { + f.completeExceptionally(new IllegalArgumentException("Scheme must be ws or wss")); + return f; + } + final String scheme = secure ? URIScheme.HTTPS.id : URIScheme.HTTP.id; + final int port = uri.getPort() > 0 ? uri.getPort() : secure ? 443 : 80; + final String host = Args.notBlank(uri.getHost(), "host"); + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + final String fullPath = uri.getRawQuery() != null ? path + "?" + uri.getRawQuery() : path; + final HttpHost target = new HttpHost(scheme, host, port); + + final String secKey = randomKey(); + final BasicHttpRequest req = new BasicHttpRequest(Method.CONNECT.name(), target, fullPath); + req.addHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket"); + req.addHeader("Sec-WebSocket-Version", "13"); + req.addHeader("Sec-WebSocket-Key", secKey); + + if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) { + final StringBuilder sb = new StringBuilder(); + for (final String p : cfg.getSubprotocols()) { + if (p != null && !p.isEmpty()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(p); + } + } + if (sb.length() > 0) { + req.addHeader("Sec-WebSocket-Protocol", sb.toString()); + } + } + + if (cfg.isPerMessageDeflateEnabled()) { + final StringBuilder ext = new StringBuilder("permessage-deflate"); + if (cfg.isOfferServerNoContextTakeover()) { + ext.append("; server_no_context_takeover"); + } + if (cfg.isOfferClientNoContextTakeover()) { + ext.append("; client_no_context_takeover"); + } + if (cfg.getOfferClientMaxWindowBits() != null) { + ext.append("; client_max_window_bits=").append(cfg.getOfferClientMaxWindowBits()); + } + if (cfg.getOfferServerMaxWindowBits() != null) { + ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits()); + } + req.addHeader("Sec-WebSocket-Extensions", ext.toString()); + } + + final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10); + requester.execute(target, new H2WebSocketExchangeHandler(req, secKey, listener, cfg, f), null, timeout, context); + return f; + } + + private static String randomKey() { + final byte[] nonce = new byte[16]; + ThreadLocalRandom.current().nextBytes(nonce); + return Base64.getEncoder().encodeToString(nonce); + } + + private static final class H2WebSocketExchangeHandler implements AsyncClientExchangeHandler { + + private final BasicHttpRequest request; + private final String key; + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private final CompletableFuture future; + private final H2WebSocket webSocket; + private final WebSocketFrameWriter writer; + private WebSocketFrameDecoder decoder; + + private ByteBuffer inbuf = ByteBuffer.allocate(8192); + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean outputPrimed = new AtomicBoolean(false); + private ExtensionChain.EncodeChain encChain; + private ExtensionChain.DecodeChain decChain; + private int assemblingOpcode = -1; + private boolean assemblingCompressed; + private ByteArrayOutputStream assemblingBytes; + + private volatile DataStreamChannel dataChannel; + + H2WebSocketExchangeHandler( + final BasicHttpRequest request, + final String key, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final CompletableFuture future) { + this.request = request; + this.key = key; + this.listener = listener; + this.cfg = cfg; + this.future = future; + this.writer = new WebSocketFrameWriter(); + this.webSocket = new H2WebSocket(); + } + + @Override + public void produceRequest(final RequestChannel channel, final HttpContext context) throws HttpException, IOException { + channel.sendRequest(request, new BasicEntityDetails(-1, null), context); + } + + @Override + public void consumeResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) throws HttpException, IOException { + if (response.getCode() != HttpStatus.SC_OK) { + future.completeExceptionally(new IllegalStateException("Unexpected status: " + response.getCode())); + return; + } + if (containsHeader(response, "Sec-WebSocket-Accept")) { + final String accept = response.getFirstHeader("Sec-WebSocket-Accept").getValue(); + try { + final String expected = expectedAccept(key); + if (!expected.equals(accept)) { + throw new ProtocolException("Invalid Sec-WebSocket-Accept"); + } + } catch (final Exception ex) { + future.completeExceptionally(ex); + return; + } + } + + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, headerValue(response, "Sec-WebSocket-Extensions")); + this.encChain = chain.isEmpty() ? null : chain.newEncodeChain(); + this.decChain = chain.isEmpty() ? null : chain.newDecodeChain(); + this.decoder = new WebSocketFrameDecoder( + cfg.getMaxFrameSize(), chain.isEmpty(), false); + + future.complete(webSocket); + listener.onOpen(webSocket); + } + + @Override + public void consumeInformation(final HttpResponse response, final HttpContext context) throws HttpException, IOException { + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) throws IOException { + if (!open.get()) { + return; + } + if (decoder == null) { + return; + } + appendToInbuf(src); + inbuf.flip(); + for (; ; ) { + final boolean has; + try { + has = decoder.decode(inbuf); + } catch (final RuntimeException ex) { + listener.onError(ex); + open.set(false); + return; + } + if (!has) { + break; + } + handleFrame(); + } + inbuf.compact(); + } + + @Override + public void streamEnd(final List trailers) throws HttpException, IOException { + open.set(false); + } + + @Override + public int available() { + if (dataChannel == null && outputPrimed.compareAndSet(false, true)) { + // Force a first produce() call to capture the output channel. + return 1; + } + return webSocket.available(); + } + + @Override + public void produce(final DataStreamChannel channel) throws IOException { + this.dataChannel = channel; + webSocket.produce(channel); + } + + @Override + public void failed(final Exception cause) { + listener.onError(cause); + open.set(false); + } + + @Override + public void releaseResources() { + open.set(false); + } + + @Override + public void cancel() { + open.set(false); + } + + private void handleFrame() { + final int op = decoder.opcode(); + final boolean fin = decoder.fin(); + final boolean r1 = decoder.rsv1(); + final boolean r2 = decoder.rsv2(); + final boolean r3 = decoder.rsv3(); + final ByteBuffer payload = decoder.payload(); + + if (r2 || r3) { + listener.onError(new WebSocketProtocolException(1002, "RSV2/RSV3 not supported")); + open.set(false); + return; + } + if (r1 && decChain == null) { + listener.onError(new WebSocketProtocolException(1002, "RSV1 without negotiated extension")); + open.set(false); + return; + } + if (FrameOpcode.isControl(op)) { + if (!fin) { + listener.onError(new WebSocketProtocolException(1002, "fragmented control frame")); + open.set(false); + return; + } + if (payload.remaining() > 125) { + listener.onError(new WebSocketProtocolException(1002, "control frame too large")); + open.set(false); + return; + } + } + switch (op) { + case FrameOpcode.PING: + listener.onPing(payload.asReadOnlyBuffer()); + if (cfg.isAutoPong()) { + webSocket.pong(payload.asReadOnlyBuffer()); + } + break; + case FrameOpcode.PONG: + listener.onPong(payload.asReadOnlyBuffer()); + break; + case FrameOpcode.CLOSE: + int code = 1005; + String reason = ""; + if (payload.remaining() == 1) { + listener.onError(new WebSocketProtocolException(1002, "Invalid close payload length")); + open.set(false); + return; + } else if (payload.remaining() >= 2) { + final ByteBuffer dup = payload.slice(); + code = CloseCodec.readCloseCode(dup); + if (!CloseCodec.isValidToReceive(code)) { + listener.onError(new WebSocketProtocolException(1002, "Invalid close code")); + open.set(false); + return; + } + if (dup.hasRemaining()) { + reason = StandardCharsets.UTF_8.decode(dup.asReadOnlyBuffer()).toString(); + } + } + listener.onClose(code, reason); + open.set(false); + break; + case FrameOpcode.TEXT: + case FrameOpcode.BINARY: + if (assemblingOpcode != -1) { + listener.onError(new WebSocketProtocolException(1002, "New data frame while fragmented message in progress")); + open.set(false); + return; + } + if (!fin) { + assemblingOpcode = op; + assemblingCompressed = r1 && decChain != null; + assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining())); + appendPayload(payload); + return; + } + deliverSingle(op, payload, r1); + break; + case FrameOpcode.CONT: + if (assemblingOpcode == -1) { + listener.onError(new WebSocketProtocolException(1002, "Unexpected continuation frame")); + open.set(false); + return; + } + appendPayload(payload); + if (fin) { + final ByteBuffer full = ByteBuffer.wrap(assemblingBytes.toByteArray()); + final int opcode = assemblingOpcode; + final boolean compressed = assemblingCompressed; + assemblingOpcode = -1; + assemblingCompressed = false; + assemblingBytes = null; + deliverSingle(opcode, full, compressed); + } + break; + default: + listener.onError(new WebSocketProtocolException(1002, "Unsupported opcode: " + op)); + open.set(false); + } + } + + private void deliverSingle(final int opcode, final ByteBuffer payload, final boolean rsv1) { + ByteBuffer data = payload.asReadOnlyBuffer(); + if (rsv1 && decChain != null) { + try { + data = ByteBuffer.wrap(decChain.decode(toBytes(data))); + } catch (final Exception ex) { + listener.onError(ex); + return; + } + } + if (opcode == FrameOpcode.TEXT) { + listener.onText(StandardCharsets.UTF_8.decode(data), true); + } else { + listener.onBinary(data, true); + } + } + + private void appendPayload(final ByteBuffer payload) { + if (assemblingBytes == null) { + assemblingBytes = new ByteArrayOutputStream(); + } + final byte[] tmp = toBytes(payload); + assemblingBytes.write(tmp, 0, tmp.length); + } + + private void appendToInbuf(final ByteBuffer src) { + if (src == null || !src.hasRemaining()) { + return; + } + if (inbuf.remaining() < src.remaining()) { + final int need = inbuf.position() + src.remaining(); + final int newCap = Math.max(inbuf.capacity() * 2, need); + final ByteBuffer bigger = ByteBuffer.allocate(newCap); + inbuf.flip(); + bigger.put(inbuf); + inbuf = bigger; + } + inbuf.put(src); + } + + private byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } + + private static boolean containsHeader(final HttpResponse r, final String name) { + return r.getFirstHeader(name) != null; + } + + private static String headerValue(final HttpResponse r, final String name) { + final Header h = r.getFirstHeader(name); + return h != null ? h.getValue() : null; + } + + private static String expectedAccept(final String key) throws Exception { + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII)); + return Base64.getEncoder().encodeToString(sha1.digest()); + } + + private final class H2WebSocket implements WebSocket { + private final ArrayDeque queue = new ArrayDeque<>(); + private int queuedBytes; + + @Override + public boolean isOpen() { + return open.get(); + } + + @Override + public boolean ping(final ByteBuffer data) { + return enqueue(writer.ping(data), false); + } + + @Override + public boolean pong(final ByteBuffer data) { + return enqueue(writer.pong(data), false); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (!finalFragment) { + throw new UnsupportedOperationException("Fragmentation not supported in H2 client"); + } + return enqueueData(FrameOpcode.TEXT, data.toString().getBytes(StandardCharsets.UTF_8)); + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (!finalFragment) { + throw new UnsupportedOperationException("Fragmentation not supported in H2 client"); + } + final byte[] bytes = toBytes(data); + return enqueueData(FrameOpcode.BINARY, bytes); + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + if (!CloseCodec.isValidToSend(statusCode)) { + throw new IllegalArgumentException("Invalid close code: " + statusCode); + } + final ByteBuffer frame = writer.close(statusCode, reason); + enqueue(frame, true); + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (fragments == null || fragments.isEmpty()) { + throw new IllegalArgumentException("fragments must not be empty"); + } + final StringBuilder sb = new StringBuilder(); + for (final CharSequence s : fragments) { + if (s != null) { + sb.append(s); + } + } + return sendText(sb, finalFragment); + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (fragments == null || fragments.isEmpty()) { + throw new IllegalArgumentException("fragments must not be empty"); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (final ByteBuffer b : fragments) { + if (b != null) { + final byte[] bytes = toBytes(b); + out.write(bytes, 0, bytes.length); + } + } + return sendBinary(ByteBuffer.wrap(out.toByteArray()), finalFragment); + } + + private boolean enqueueData(final int opcode, final byte[] payload) { + if (!open.get()) { + return false; + } + int rsv = 0; + byte[] out = payload; + if (encChain != null) { + final WebSocketExtensionChain.Encoded encRes = encChain.encode(out, true, true); + out = encRes.payload; + if (encRes.setRsvOnFirst) { + rsv = FrameHeaderBits.RSV1; + } + } + final ByteBuffer frame = writer.frameWithRSV(opcode, ByteBuffer.wrap(out), true, true, rsv); + return enqueue(frame, false); + } + + private boolean enqueue(final ByteBuffer frame, final boolean closeAfter) { + if (!open.get()) { + return false; + } + synchronized (queue) { + queue.add(frame); + queuedBytes += frame.remaining(); + } + if (dataChannel != null) { + dataChannel.requestOutput(); + } + if (closeAfter) { + open.set(false); + } + return true; + } + + int available() { + synchronized (queue) { + return queuedBytes; + } + } + + void produce(final DataStreamChannel channel) throws IOException { + while (true) { + final ByteBuffer buf; + synchronized (queue) { + buf = queue.peek(); + } + if (buf == null) { + return; + } + final int n = channel.write(buf); + if (n == 0) { + channel.requestOutput(); + return; + } + if (!buf.hasRemaining()) { + synchronized (queue) { + queue.poll(); + queuedBytes = Math.max(0, queuedBytes - n); + } + } else { + synchronized (queue) { + queuedBytes = Math.max(0, queuedBytes - n); + } + channel.requestOutput(); + return; + } + } + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java new file mode 100644 index 0000000000..deebeddec1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java @@ -0,0 +1,59 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Minimal pluggable protocol strategy. One impl for H1 (RFC6455), + * one for H2 Extended CONNECT (RFC8441). + */ +@Internal +public interface WebSocketProtocolStrategy { + + /** + * Establish a WebSocket connection using a specific HTTP transport/protocol. + * + * @param uri ws:// or wss:// target + * @param listener user listener for WS events + * @param cfg client config (timeouts, subprotocols, PMCE offer, etc.) + * @param context optional HttpContext (may be {@code null}) + * @return future completing with a connected {@link WebSocket} or exceptionally on failure + */ + CompletableFuture connect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java new file mode 100644 index 0000000000..5820f61daa --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java new file mode 100644 index 0000000000..e650658364 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * High-level asynchronous WebSocket client. + * + *

Provides {@code WebSocketClient}, which performs the HTTP/1.1 upgrade + * (RFC 6455) and exposes an application-level {@code WebSocket}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java new file mode 100644 index 0000000000..f7b59da2f1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Client-side WebSocket support built on top of Apache HttpClient. + * + *

This package provides the public API for establishing and using + * WebSocket connections according to RFC 6455. WebSocket sessions + * are created by upgrading an HTTP request and are backed internally + * by the non-blocking I/O reactor used by the HttpClient async APIs.

+ * + *

Core abstractions

+ *
    + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocket WebSocket} – + * application view of a single WebSocket connection, used to send + * text and binary messages and initiate the close handshake.
  • + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocketListener WebSocketListener} – + * callback interface that receives inbound messages, pings, pongs, + * errors, and close notifications.
  • + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocketClientConfig WebSocketClientConfig} – + * immutable configuration for timeouts, maximum frame and message + * sizes, auto-pong behaviour, and buffer management.
  • + *
  • {@link org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient CloseableWebSocketClient} – + * high-level client for establishing WebSocket connections.
  • + *
  • {@link org.apache.hc.client5.http.websocket.client.WebSocketClients WebSocketClients} and + * {@link org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder WebSocketClientBuilder} – + * factory and builder for creating and configuring WebSocket clients.
  • + *
+ * + *

Threading model

+ *

Outbound operations on {@code WebSocket} are thread-safe and may be + * invoked from arbitrary application threads. Inbound callbacks on + * {@code WebSocketListener} are normally executed on I/O dispatcher + * threads; listeners should avoid long blocking operations.

+ * + *

Close handshake

+ *

The implementation follows the close handshake defined in RFC 6455. + * Applications should initiate shutdown via + * {@link org.apache.hc.client5.http.websocket.api.WebSocket#close(int, String)} + * and treat receipt of a close frame as a terminal event. The configured + * {@code closeWaitTimeout} controls how long the client will wait for the + * peer's close frame before the underlying connection is closed.

+ * + *

Classes in {@code org.apache.hc.core5.websocket} subpackages and + * {@code org.apache.hc.client5.http.websocket.transport} are internal + * implementation details and are not intended for direct use.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java new file mode 100644 index 0000000000..d552bad1ef --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java @@ -0,0 +1,172 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class WebSocketFrameDecoder { + private final int maxFrameSize; + private final boolean strictNoExtensions; + + private int opcode; + private boolean fin; + private boolean rsv1, rsv2, rsv3; + private ByteBuffer payload = ByteBuffer.allocate(0); + private final boolean expectMasked; + + + + public WebSocketFrameDecoder(final int maxFrameSize, final boolean strictNoExtensions) { + this(maxFrameSize, strictNoExtensions, false); + } + + public WebSocketFrameDecoder(final int maxFrameSize) { + this(maxFrameSize, true, false); + } + + public WebSocketFrameDecoder(final int maxFrameSize, + final boolean strictNoExtensions, + final boolean expectMasked) { + this.maxFrameSize = maxFrameSize; + this.strictNoExtensions = strictNoExtensions; + this.expectMasked = expectMasked; + } + + public boolean decode(final ByteBuffer in) { + in.mark(); + if (in.remaining() < 2) { + in.reset(); + return false; + } + + final int b0 = in.get() & 0xFF; + final int b1 = in.get() & 0xFF; + + fin = (b0 & 0x80) != 0; + rsv1 = (b0 & 0x40) != 0; + rsv2 = (b0 & 0x20) != 0; + rsv3 = (b0 & 0x10) != 0; + + if (strictNoExtensions && (rsv1 || rsv2 || rsv3)) { + throw new WebSocketProtocolException(1002, "RSV bits set without extension"); + } + + opcode = b0 & 0x0F; + + if (opcode != 0 && opcode != 1 && opcode != 2 && opcode != 8 && opcode != 9 && opcode != 10) { + throw new WebSocketProtocolException(1002, "Reserved/unknown opcode: " + opcode); + } + + final boolean masked = (b1 & 0x80) != 0; + long len = b1 & 0x7F; + + // Mode-aware masking rule + if (masked != expectMasked) { + if (expectMasked) { + // server decoding client frames: clients MUST mask + throw new WebSocketProtocolException(1002, "Client frame is not masked"); + } else { + // client decoding server frames: servers MUST NOT mask + throw new WebSocketProtocolException(1002, "Server frame is masked"); + } + } + + if (len == 126) { + if (in.remaining() < 2) { + in.reset(); + return false; + } + len = in.getShort() & 0xFFFF; + } else if (len == 127) { + if (in.remaining() < 8) { + in.reset(); + return false; + } + final long l = in.getLong(); + if (l < 0) { + throw new WebSocketProtocolException(1002, "Negative length"); + } + len = l; + } + + if (FrameOpcode.isControl(opcode)) { + if (!fin) { + throw new WebSocketProtocolException(1002, "fragmented control frame"); + } + if (len > 125) { + throw new WebSocketProtocolException(1002, "control frame too large"); + } + // (RSV checks above already cover RSV!=0) + } + + if (len > Integer.MAX_VALUE || maxFrameSize > 0 && len > maxFrameSize) { + throw new WebSocketProtocolException(1009, "Frame too large: " + len); + } + + if (in.remaining() < len) { + in.reset(); + return false; + } + + final ByteBuffer data = ByteBuffer.allocate((int) len); + for (int i = 0; i < len; i++) { + data.put(in.get()); + } + data.flip(); + payload = data.asReadOnlyBuffer(); + return true; + } + + public int opcode() { + return opcode; + } + + public boolean fin() { + return fin; + } + + public boolean rsv1() { + return rsv1; + } + + public boolean rsv2() { + return rsv2; + } + + public boolean rsv3() { + return rsv3; + } + + public ByteBuffer payload() { + return payload.asReadOnlyBuffer(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java new file mode 100644 index 0000000000..8e24e00caa --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java @@ -0,0 +1,449 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeoutException; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.message.CloseCodec; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Timeout; + +/** + * Inbound path: decoding, validation, fragment assembly, close handshake. + */ +@Internal +final class WebSocketInbound { + + private final WebSocketSessionState s; + private final WebSocketOutbound out; + + WebSocketInbound(final WebSocketSessionState state, final WebSocketOutbound outbound) { + this.s = state; + this.out = outbound; + } + + // ---- lifecycle ---- + void onConnected(final IOSession ioSession) { + ioSession.setSocketTimeout(Timeout.DISABLED); + ioSession.setEventMask(EventMask.READ | EventMask.WRITE); + } + + void onTimeout(final IOSession ioSession, final Timeout timeout) { + try { + final String msg = "I/O timeout: " + (timeout != null ? timeout : Timeout.ZERO_MILLISECONDS); + s.listener.onError(new TimeoutException(msg)); + } catch (final Throwable ignore) { + } + } + + void onException(final IOSession ioSession, final Exception cause) { + try { + s.listener.onError(cause); + } catch (final Throwable ignore) { + } + } + + void onDisconnected(final IOSession ioSession) { + if (s.open.getAndSet(false)) { + try { + s.listener.onClose(1006, "abnormal closure"); + } catch (final Throwable ignore) { + } + } + if (s.readBuf != null) { + s.bufferPool.release(s.readBuf); + s.readBuf = null; + } + out.drainAndRelease(); + ioSession.clearEvent(EventMask.READ | EventMask.WRITE); + } + + void onInputReady(final IOSession ioSession, final ByteBuffer src) { + try { + if (!s.open.get() && !s.closeSent.get()) { + return; + } + + if (s.readBuf == null) { + s.readBuf = s.bufferPool.acquire(); + if (s.readBuf == null) { + return; + } + } + + if (src != null && src.hasRemaining()) { + appendToInbuf(src); + } + + int n; + do { + ByteBuffer rb = s.readBuf; + if (rb == null) { + rb = s.bufferPool.acquire(); + if (rb == null) { + return; + } + s.readBuf = rb; + } + rb.clear(); + n = ioSession.read(rb); + if (n > 0) { + rb.flip(); + appendToInbuf(rb); + } + } while (n > 0); + + if (n < 0) { + onDisconnected(ioSession); + return; + } + + s.inbuf.flip(); + for (; ; ) { + final boolean has; + try { + has = s.decoder.decode(s.inbuf); + } catch (final RuntimeException rte) { + final int code = rte instanceof WebSocketProtocolException + ? ((WebSocketProtocolException) rte).closeCode + : 1002; + initiateCloseAndWait(ioSession, code, rte.getMessage()); + s.inbuf.clear(); + return; + } + if (!has) { + break; + } + + final int op = s.decoder.opcode(); + final boolean fin = s.decoder.fin(); + final boolean r1 = s.decoder.rsv1(); + final boolean r2 = s.decoder.rsv2(); + final boolean r3 = s.decoder.rsv3(); + final ByteBuffer payload = s.decoder.payload(); + + if (r2 || r3) { + initiateCloseAndWait(ioSession, 1002, "RSV2/RSV3 not supported"); + s.inbuf.clear(); + return; + } + if (r1 && s.decChain == null) { + initiateCloseAndWait(ioSession, 1002, "RSV1 without negotiated extension"); + s.inbuf.clear(); + return; + } + + if (s.closeSent.get() && op != FrameOpcode.CLOSE) { + continue; + } + + if (FrameOpcode.isControl(op)) { + if (!fin) { + initiateCloseAndWait(ioSession, 1002, "fragmented control frame"); + s.inbuf.clear(); + return; + } + if (payload.remaining() > 125) { + initiateCloseAndWait(ioSession, 1002, "control frame too large"); + s.inbuf.clear(); + return; + } + } + + switch (op) { + case FrameOpcode.PING: { + try { + s.listener.onPing(payload.asReadOnlyBuffer()); + } catch (final Throwable ignore) { + } + if (s.cfg.isAutoPong()) { + out.enqueueCtrl(out.pooledFrame(FrameOpcode.PONG, payload.asReadOnlyBuffer(), true)); + } + break; + } + case FrameOpcode.PONG: { + try { + s.listener.onPong(payload.asReadOnlyBuffer()); + } catch (final Throwable ignore) { + } + break; + } + case FrameOpcode.CLOSE: { + final ByteBuffer ro = payload.asReadOnlyBuffer(); + int code = 1005; + String reason = ""; + final int len = ro.remaining(); + + if (len == 1) { + initiateCloseAndWait(ioSession, 1002, "Close frame length of 1 is invalid"); + s.inbuf.clear(); + return; + } else if (len >= 2) { + final ByteBuffer dup = ro.slice(); + code = CloseCodec.readCloseCode(dup); + + if (!CloseCodec.isValidToReceive(code)) { + initiateCloseAndWait(ioSession, 1002, "Invalid close code: " + code); + s.inbuf.clear(); + return; + } + + if (dup.hasRemaining()) { + final CharsetDecoder dec = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + reason = dec.decode(dup.asReadOnlyBuffer()).toString(); + } catch (final CharacterCodingException badUtf8) { + initiateCloseAndWait(ioSession, 1007, "Invalid UTF-8 in close reason"); + s.inbuf.clear(); + return; + } + } + } + + notifyCloseOnce(code, reason); + + s.closeReceived.set(true); + + if (!s.closeSent.get()) { + out.enqueueCtrl(out.pooledCloseEcho(ro)); + } + + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + s.closeAfterFlush = true; + ioSession.clearEvent(EventMask.READ); + ioSession.setEvent(EventMask.WRITE); + s.inbuf.clear(); + return; + } + case FrameOpcode.CONT: { + if (s.assemblingOpcode == -1) { + initiateCloseAndWait(ioSession, 1002, "Unexpected continuation frame"); + s.inbuf.clear(); + return; + } + if (r1) { + initiateCloseAndWait(ioSession, 1002, "RSV1 set on continuation"); + s.inbuf.clear(); + return; + } + appendToMessage(payload, ioSession); + if (fin) { + deliverAssembledMessage(); + } + break; + } + case FrameOpcode.TEXT: + case FrameOpcode.BINARY: { + if (s.assemblingOpcode != -1) { + initiateCloseAndWait(ioSession, 1002, "New data frame while fragmented message in progress"); + s.inbuf.clear(); + return; + } + if (!fin) { + startMessage(op, payload, r1, ioSession); + break; + } + if (s.cfg.getMaxMessageSize() > 0 && payload.remaining() > s.cfg.getMaxMessageSize()) { + initiateCloseAndWait(ioSession, 1009, "Message too big"); + break; + } + if (r1 && s.decChain != null) { + final byte[] comp = toBytes(payload); + final byte[] plain; + try { + plain = s.decChain.decode(comp); + } catch (final Exception e) { + initiateCloseAndWait(ioSession, 1007, "Extension decode failed"); + s.inbuf.clear(); + return; + } + deliverSingle(op, ByteBuffer.wrap(plain)); + } else { + deliverSingle(op, payload.asReadOnlyBuffer()); + } + break; + } + default: { + initiateCloseAndWait(ioSession, 1002, "Unsupported opcode: " + op); + s.inbuf.clear(); + return; + } + } + } + s.inbuf.compact(); + } catch (final Exception ex) { + onException(ioSession, ex); + ioSession.close(CloseMode.GRACEFUL); + } + } + + private void appendToInbuf(final ByteBuffer src) { + if (src == null || !src.hasRemaining()) { + return; + } + if (s.inbuf.remaining() < src.remaining()) { + final int need = s.inbuf.position() + src.remaining(); + final int newCap = Math.max(s.inbuf.capacity() * 2, need); + final ByteBuffer bigger = ByteBuffer.allocate(newCap); + s.inbuf.flip(); + bigger.put(s.inbuf); + s.inbuf = bigger; + } + s.inbuf.put(src); + } + + private void startMessage(final int opcode, final ByteBuffer payload, final boolean rsv1, final IOSession ioSession) { + s.assemblingOpcode = opcode; + s.assemblingCompressed = rsv1 && s.decChain != null; + s.assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining())); + s.assemblingSize = 0L; + appendToMessage(payload, ioSession); + } + + private void appendToMessage(final ByteBuffer payload, final IOSession ioSession) { + final ByteBuffer dup = payload.asReadOnlyBuffer(); + final int n = dup.remaining(); + s.assemblingSize += n; + if (s.cfg.getMaxMessageSize() > 0 && s.assemblingSize > s.cfg.getMaxMessageSize()) { + initiateCloseAndWait(ioSession, 1009, "Message too big"); + return; + } + final byte[] tmp = new byte[n]; + dup.get(tmp); + s.assemblingBytes.write(tmp, 0, n); + } + + private void deliverAssembledMessage() { + final byte[] body = s.assemblingBytes.toByteArray(); + final int op = s.assemblingOpcode; + final boolean compressed = s.assemblingCompressed; + + s.assemblingOpcode = -1; + s.assemblingCompressed = false; + s.assemblingBytes = null; + s.assemblingSize = 0L; + + byte[] data = body; + if (compressed && s.decChain != null) { + try { + data = s.decChain.decode(body); + } catch (final Exception e) { + try { + s.listener.onError(e); + } catch (final Throwable ignore) { + } + return; + } + } + + if (op == FrameOpcode.TEXT) { + final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + final CharBuffer cb = dec.decode(ByteBuffer.wrap(data)); + try { + s.listener.onText(cb, true); + } catch (final Throwable ignore) { + } + } catch (final CharacterCodingException cce) { + initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message"); + } + } else if (op == FrameOpcode.BINARY) { + try { + s.listener.onBinary(ByteBuffer.wrap(data).asReadOnlyBuffer(), true); + } catch (final Throwable ignore) { + } + } + } + + private void deliverSingle(final int op, final ByteBuffer payloadRO) { + if (op == FrameOpcode.TEXT) { + final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + final CharBuffer cb = dec.decode(payloadRO); + try { + s.listener.onText(cb, true); + } catch (final Throwable ignore) { + } + } catch (final CharacterCodingException cce) { + initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message"); + } + } else if (op == FrameOpcode.BINARY) { + try { + s.listener.onBinary(payloadRO, true); + } catch (final Throwable ignore) { + } + } + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } + + private void initiateCloseAndWait(final IOSession ioSession, final int code, final String reason) { + if (!s.closeSent.get()) { + try { + final String truncated = CloseCodec.truncateReasonUtf8(reason); + final byte[] payloadBytes = CloseCodec.encode(code, truncated); + out.enqueueCtrl(out.pooledFrame(FrameOpcode.CLOSE, ByteBuffer.wrap(payloadBytes), true)); + } catch (final Throwable ignore) { + } + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + } + notifyCloseOnce(code, reason); + } + + private void notifyCloseOnce(final int code, final String reason) { + if (s.open.getAndSet(false)) { + try { + s.listener.onClose(code, reason == null ? "" : reason); + } catch (final Throwable ignore) { + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java new file mode 100644 index 0000000000..b50e4b7740 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java @@ -0,0 +1,121 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.command.ShutdownCommand; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Timeout; + +/** + * RFC6455/7692 WebSocket handler front-end. Delegates to WsInbound / WsOutbound. + */ +@Internal +public final class WebSocketIoHandler implements IOEventHandler { + + private final WebSocketSessionState state; + private final WebSocketInbound inbound; + private final WebSocketOutbound outbound; + private final AsyncClientEndpoint endpoint; + private final AtomicBoolean endpointReleased; + + public WebSocketIoHandler(final ProtocolIOSession session, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain, + final AsyncClientEndpoint endpoint) { + this.state = new WebSocketSessionState(session, listener, cfg, chain); + this.outbound = new WebSocketOutbound(state); + this.inbound = new WebSocketInbound(state, outbound); + this.endpoint = endpoint; + this.endpointReleased = new AtomicBoolean(false); + } + + /** + * Expose the application WebSocket facade. + */ + public WebSocket exposeWebSocket() { + return outbound.facade(); + } + + // ---- IOEventHandler ---- + @Override + public void connected(final IOSession ioSession) { + inbound.onConnected(ioSession); + } + + @Override + public void inputReady(final IOSession ioSession, final ByteBuffer src) { + inbound.onInputReady(ioSession, src); + } + + @Override + public void outputReady(final IOSession ioSession) { + outbound.onOutputReady(ioSession); + } + + @Override + public void timeout(final IOSession ioSession, final Timeout timeout) { + inbound.onTimeout(ioSession, timeout); + // Best-effort graceful close on timeout + ioSession.close(CloseMode.GRACEFUL); + } + + @Override + public void exception(final IOSession ioSession, final Exception cause) { + inbound.onException(ioSession, cause); + ioSession.close(CloseMode.GRACEFUL); + } + + @Override + public void disconnected(final IOSession ioSession) { + inbound.onDisconnected(ioSession); + ioSession.clearEvent(EventMask.READ | EventMask.WRITE); + // Ensure the underlying protocol session does not linger + state.session.enqueue(new ShutdownCommand(CloseMode.GRACEFUL), Command.Priority.IMMEDIATE); + if (endpoint != null && endpointReleased.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + // best effort + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java new file mode 100644 index 0000000000..f928e848af --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java @@ -0,0 +1,577 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.message.CloseCodec; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Args; + +/** + * Outbound path: frame building, queues, writing, and the app-facing WebSocket facade. + */ +@Internal +final class WebSocketOutbound { + + static final class OutFrame { + + final ByteBuffer buf; + final boolean pooled; + + OutFrame(final ByteBuffer buf, final boolean pooled) { + this.buf = buf; + this.pooled = pooled; + } + } + + private final WebSocketSessionState s; + private final WebSocket facade; + + WebSocketOutbound(final WebSocketSessionState s) { + this.s = s; + this.facade = new Facade(); + } + + WebSocket facade() { + return facade; + } + + // ---------------------------------------------------- IO writing --------- + + void onOutputReady(final IOSession ioSession) { + try { + int framesThisTick = 0; + + while (framesThisTick < s.maxFramesPerTick) { + + if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) { + final int written = ioSession.write(s.activeWrite.buf); + if (written == 0) { + ioSession.setEvent(EventMask.WRITE); + return; + } + if (!s.activeWrite.buf.hasRemaining()) { + release(s.activeWrite); + s.activeWrite = null; + framesThisTick++; + } else { + ioSession.setEvent(EventMask.WRITE); + return; + } + continue; + } + + final OutFrame ctrl = s.ctrlOutbound.poll(); + if (ctrl != null) { + s.activeWrite = ctrl; + continue; + } + + final OutFrame data = s.dataOutbound.poll(); + if (data != null) { + s.activeWrite = data; + continue; + } + + ioSession.clearEvent(EventMask.WRITE); + if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) { + ioSession.close(CloseMode.GRACEFUL); + } + return; + } + + if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) { + ioSession.setEvent(EventMask.WRITE); + } else { + ioSession.clearEvent(EventMask.WRITE); + } + + if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) { + ioSession.close(CloseMode.GRACEFUL); + } + + } catch (final Exception ex) { + try { + s.listener.onError(ex); + } finally { + s.session.close(CloseMode.GRACEFUL); + } + } + } + + private void release(final OutFrame frame) { + if (frame.pooled) { + s.bufferPool.release(frame.buf); + } + } + + boolean enqueueCtrl(final OutFrame frame) { + final boolean closeFrame = isCloseFrame(frame.buf); + + if (!closeFrame && (!s.open.get() || s.closeSent.get())) { + release(frame); + return false; + } + + if (closeFrame) { + if (!s.closeSent.compareAndSet(false, true)) { + release(frame); + return false; + } + } else { + final int max = s.cfg.getMaxOutboundControlQueue(); + if (max > 0 && s.ctrlOutbound.size() >= max) { + release(frame); + return false; + } + } + s.ctrlOutbound.offer(frame); + s.session.setEvent(EventMask.WRITE); + return true; + } + + + boolean enqueueData(final OutFrame frame) { + if (!s.open.get() || s.closeSent.get()) { + release(frame); + return false; + } + s.dataOutbound.offer(frame); + s.session.setEvent(EventMask.WRITE); + return true; + } + + private static boolean isCloseFrame(final ByteBuffer buf) { + if (buf.remaining() < 2) { + return false; + } + final int pos = buf.position(); + final byte b1 = buf.get(pos); + final int opcode = b1 & 0x0F; + return opcode == FrameOpcode.CLOSE; + } + + // package-private so WebSocketInbound can use them + OutFrame pooledFrame(final int opcode, final ByteBuffer payload, final boolean fin) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; // 2-byte header + 4-byte mask + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; // 4-byte header + 4-byte mask + } else { + headerEstimate = 10 + 4; // 10-byte header + 4-byte mask + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf; + final boolean pooled; + if (totalSize <= s.bufferPool.getBufferSize()) { + buf = s.bufferPool.acquire(); + pooled = true; + } else { + buf = ByteBuffer.allocate(totalSize); + pooled = false; + } + + buf.clear(); + // opcode (int), payload (ByteBuffer), fin (boolean), mask (boolean), out (ByteBuffer) + s.writer.frameInto(opcode, ro, fin, true, buf); + buf.flip(); + + return new OutFrame(buf, pooled); + } + + // package-private for outbound compression (RSV1 when negotiated) + OutFrame pooledFrameWithRsv(final int opcode, final ByteBuffer payload, final boolean fin, final int rsvBits) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; + } else { + headerEstimate = 10 + 4; + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf; + final boolean pooled; + if (totalSize <= s.bufferPool.getBufferSize()) { + buf = s.bufferPool.acquire(); + pooled = true; + } else { + buf = ByteBuffer.allocate(totalSize); + pooled = false; + } + + buf.clear(); + s.writer.frameIntoWithRSV(opcode, ro, fin, true, rsvBits, buf); + buf.flip(); + + return new OutFrame(buf, pooled); + } + + // package-private so WebSocketInbound can use it for close echo + OutFrame pooledCloseEcho(final ByteBuffer payload) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; + } else { + headerEstimate = 10 + 4; + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf; + final boolean pooled; + if (totalSize <= s.bufferPool.getBufferSize()) { + buf = s.bufferPool.acquire(); + pooled = true; + } else { + buf = ByteBuffer.allocate(totalSize); + pooled = false; + } + + buf.clear(); + s.writer.frameInto(FrameOpcode.CLOSE, ro, true, true, buf); + buf.flip(); + + return new OutFrame(buf, pooled); + } + + // package-private: used by WebSocketInbound.onDisconnected() + void drainAndRelease() { + if (s.activeWrite != null) { + release(s.activeWrite); + s.activeWrite = null; + } + OutFrame f; + while ((f = s.ctrlOutbound.poll()) != null) { + release(f); + } + while ((f = s.dataOutbound.poll()) != null) { + release(f); + } + } + + // --------------------------------------------------------- Facade -------- + + private final class Facade implements WebSocket { + + @Override + public boolean isOpen() { + return s.open.get() && !s.closeSent.get(); + } + + @Override + public boolean ping(final ByteBuffer data) { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + if (ro.remaining() > 125) { + return false; + } + return enqueueCtrl(pooledFrame(FrameOpcode.PING, ro, true)); + } + + @Override + public boolean pong(final ByteBuffer data) { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + if (ro.remaining() > 125) { + return false; + } + return enqueueCtrl(pooledFrame(FrameOpcode.PONG, ro, true)); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || data == null) { + return false; + } + final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(data.toString()); + return sendData(FrameOpcode.TEXT, utf8, finalFragment); + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || data == null) { + return false; + } + return sendData(FrameOpcode.BINARY, data, finalFragment); + } + + private boolean sendData(final int opcode, final ByteBuffer data, final boolean fin) { + synchronized (s.writeLock) { + if (s.encChain != null && s.outOpcode == -1 && fin) { + // Compress the whole message, then fragment the compressed payload. + final byte[] plain = toBytes(data); + final WebSocketExtensionChain.Encoded enc = + s.encChain.encode(plain, true, true); + ByteBuffer ro = ByteBuffer.wrap(enc.payload); + int currentOpcode = opcode; + boolean firstFragment = true; + if (!ro.hasRemaining()) { + ro = ByteBuffer.allocate(0); + } + do { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final int n = Math.min(ro.remaining(), s.outChunk); + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + final boolean lastSlice = !ro.hasRemaining(); + final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0; + if (!enqueueData(pooledFrameWithRsv(currentOpcode, slice, lastSlice, rsv))) { + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } while (ro.hasRemaining()); + return true; + } + + int currentOpcode = s.outOpcode == -1 ? opcode : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = opcode; + } + + final ByteBuffer ro = data.asReadOnlyBuffer(); + boolean ok = true; + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + while (ro.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + ok = false; + break; + } + + final int n = Math.min(ro.remaining(), s.outChunk); + + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + + final boolean lastSlice = !ro.hasRemaining() && fin; + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + ok = false; + break; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + + if (fin || !ok) { + s.outOpcode = -1; + } + return ok; + } + } + + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) { + return false; + } + synchronized (s.writeLock) { + int currentOpcode = s.outOpcode == -1 ? FrameOpcode.TEXT : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = FrameOpcode.TEXT; + } + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + for (int i = 0; i < fragments.size(); i++) { + final CharSequence part = Args.notNull(fragments.get(i), "fragment"); + final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(part.toString()); + final ByteBuffer ro = utf8.asReadOnlyBuffer(); + + while (ro.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + s.outOpcode = -1; + return false; + } + final int n = Math.min(ro.remaining(), s.outChunk); + + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + + final boolean isLastFragment = i == fragments.size() - 1; + final boolean lastSlice = !ro.hasRemaining() && isLastFragment && finalFragment; + + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + } + + if (finalFragment) { + s.outOpcode = -1; + } + return true; + } + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) { + return false; + } + synchronized (s.writeLock) { + int currentOpcode = s.outOpcode == -1 ? FrameOpcode.BINARY : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = FrameOpcode.BINARY; + } + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + for (int i = 0; i < fragments.size(); i++) { + final ByteBuffer src = Args.notNull(fragments.get(i), "fragment").asReadOnlyBuffer(); + + while (src.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + s.outOpcode = -1; + return false; + } + final int n = Math.min(src.remaining(), s.outChunk); + + final int oldLimit = src.limit(); + final int newLimit = src.position() + n; + src.limit(newLimit); + final ByteBuffer slice = src.slice(); + src.limit(oldLimit); + src.position(newLimit); + + final boolean isLastFragment = i == fragments.size() - 1; + final boolean lastSlice = !src.hasRemaining() && isLastFragment && finalFragment; + + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + } + + if (finalFragment) { + s.outOpcode = -1; + } + return true; + } + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + final CompletableFuture future = new CompletableFuture<>(); + + if (!s.open.get()) { + future.completeExceptionally( + new IllegalStateException("WebSocket is already closed")); + return future; + } + + if (!CloseCodec.isValidToSend(statusCode)) { + future.completeExceptionally( + new IllegalArgumentException("Invalid close status code: " + statusCode)); + return future; + } + + final String truncated = CloseCodec.truncateReasonUtf8(reason); + final byte[] payloadBytes = CloseCodec.encode(statusCode, truncated); + final ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + + if (!enqueueCtrl(pooledFrame(FrameOpcode.CLOSE, payload, true))) { + future.completeExceptionally( + new IllegalStateException("WebSocket is closing or already closed")); + return future; + } + + // cfg.getCloseWaitTimeout() is a Timeout, IOSession.setSocketTimeout(Timeout) + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + future.complete(null); + return future; + } + } + + private OutFrame buildDataFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean firstFragment) { + if (s.encChain == null) { + return pooledFrame(opcode, payload, fin); + } + final byte[] plain = toBytes(payload); + final WebSocketExtensionChain.Encoded enc = + s.encChain.encode(plain, firstFragment, fin); + final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0; + return pooledFrameWithRsv(opcode, ByteBuffer.wrap(enc.payload), fin, rsv); + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java new file mode 100644 index 0000000000..34614d5278 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java @@ -0,0 +1,120 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter; +import org.apache.hc.core5.websocket.util.ByteBufferPool; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.reactor.ProtocolIOSession; + +/** + * Shared state & resources. + */ +@Internal +final class WebSocketSessionState { + + // External + final ProtocolIOSession session; + final WebSocketListener listener; + final WebSocketClientConfig cfg; + + // Extensions + final ExtensionChain.EncodeChain encChain; + final ExtensionChain.DecodeChain decChain; + final int rsvMask; + + // Buffers & codec + final ByteBufferPool bufferPool; + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final WebSocketFrameDecoder decoder; + + // Read side + ByteBuffer readBuf; + ByteBuffer inbuf = ByteBuffer.allocate(4096); + + // Outbound queues + final ConcurrentLinkedQueue ctrlOutbound = new ConcurrentLinkedQueue<>(); + final ConcurrentLinkedQueue dataOutbound = new ConcurrentLinkedQueue<>(); + WebSocketOutbound.OutFrame activeWrite = null; + + // Flags / locks + final AtomicBoolean open = new AtomicBoolean(true); + final AtomicBoolean closeSent = new AtomicBoolean(false); + final AtomicBoolean closeReceived = new AtomicBoolean(false); + volatile boolean closeAfterFlush = false; + final Object writeLock = new Object(); + + // Message assembly + int assemblingOpcode = -1; + boolean assemblingCompressed = false; + ByteArrayOutputStream assemblingBytes = null; + long assemblingSize = 0L; + + // Outbound fragmentation + int outOpcode = -1; + final int outChunk; + final int maxFramesPerTick; + + WebSocketSessionState(final ProtocolIOSession session, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain) { + this.session = session; + this.listener = listener; + this.cfg = cfg; + + this.decoder = new WebSocketFrameDecoder(cfg.getMaxFrameSize(), false); + + this.outChunk = Math.max(256, cfg.getOutgoingChunkSize()); + this.maxFramesPerTick = Math.max(1, cfg.getMaxFramesPerTick()); + + if (chain != null && !chain.isEmpty()) { + this.encChain = chain.newEncodeChain(); + this.decChain = chain.newDecodeChain(); + this.rsvMask = chain.rsvMask(); + } else { + this.encChain = null; + this.decChain = null; + this.rsvMask = 0; + } + + final int poolBufSize = Math.max(8192, this.outChunk); + final int poolCapacity = Math.max(16, cfg.getIoPoolCapacity()); + this.bufferPool = new ByteBufferPool(poolBufSize, poolCapacity, cfg.isDirectBuffers()); + + // Borrow one read buffer upfront + this.readBuf = bufferPool.acquire(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java new file mode 100644 index 0000000000..08be8953ae --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java @@ -0,0 +1,114 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Bridges HttpCore protocol upgrade to a WebSocket {@link WebSocketIoHandler}. + * + *

IMPORTANT: This class does NOT call {@link WebSocketListener#onOpen(WebSocket)}. + * The caller performs notification after {@code switchProtocol(...)} completes.

+ */ +@Internal +public final class WebSocketUpgrader implements ProtocolUpgradeHandler { + + private static final Logger LOG = LoggerFactory.getLogger(WebSocketUpgrader.class); + + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private final ExtensionChain chain; + private final AsyncClientEndpoint endpoint; + + /** + * The WebSocket facade created during {@link #upgrade}. + */ + private volatile WebSocket webSocket; + + public WebSocketUpgrader( + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain) { + this(listener, cfg, chain, null); + } + + public WebSocketUpgrader( + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain, + final AsyncClientEndpoint endpoint) { + this.listener = listener; + this.cfg = cfg; + this.chain = chain; + this.endpoint = endpoint; + } + + /** + * Returns the {@link WebSocket} created during {@link #upgrade}. + */ + public WebSocket getWebSocket() { + return webSocket; + } + + @Override + public void upgrade(final ProtocolIOSession ioSession, + final FutureCallback callback) { + try { + if (LOG.isDebugEnabled()) { + LOG.debug("Installing WsHandler on {}", ioSession); + } + + final WebSocketIoHandler handler = new WebSocketIoHandler(ioSession, listener, cfg, chain, endpoint); + ioSession.upgrade(handler); + + this.webSocket = handler.exposeWebSocket(); + + if (callback != null) { + callback.completed(ioSession); + } + } catch (final Exception ex) { + if (LOG.isDebugEnabled()) { + LOG.debug("WebSocket upgrade failed", ex); + } + if (callback != null) { + callback.failed(ex); + } else { + throw ex; + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java new file mode 100644 index 0000000000..67cff0dbea --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java @@ -0,0 +1,37 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Integration with Apache HttpCore I/O reactor. + * + *

Protocol upgrade hooks and the reactor {@code IOEventHandler} that + * implements RFC 6455/7692 on top of HttpCore. Internal API — subject + * to change without notice.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.transport; diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/H2WebSocketEchoIT.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/H2WebSocketEchoIT.java new file mode 100644 index 0000000000..df9891281c --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/H2WebSocketEchoIT.java @@ -0,0 +1,162 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class H2WebSocketEchoIT { + + private WebSocketH2Server server; + + @BeforeEach + void setUp() throws Exception { + server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(0) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + }) + .create(); + server.start(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.initiateShutdown(); + server.stop(); + } + } + + @Test + void echoesOverHttp2ExtendedConnect() throws Exception { + final URI uri = URI.create("ws://localhost:" + server.getLocalPort() + "/echo"); + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference echo = new AtomicReference<>(); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echo.set(text.toString()); + done.countDown(); + ws.close(1000, "done"); + } + + @Override + public void onClose(final int code, final String reason) { + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + assertTrue(done.await(10, TimeUnit.SECONDS), "timed out waiting for echo"); + assertEquals("hello-h2", echo.get()); + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java new file mode 100644 index 0000000000..d4d94b2ff0 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java @@ -0,0 +1,57 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Test; + +final class WebSocketClientConfigTest { + + @Test + void builderDefaultsAndCustom() { + final WebSocketClientConfig def = WebSocketClientConfig.custom().build(); + assertTrue(def.isAutoPong()); + assertTrue(def.getMaxFrameSize() > 0); + assertTrue(def.getMaxMessageSize() > 0); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setAutoPong(false) + .setMaxFrameSize(1024) + .setMaxMessageSize(2048) + .setConnectTimeout(Timeout.ofSeconds(3)) + .build(); + + assertFalse(cfg.isAutoPong()); + assertEquals(1024, cfg.getMaxFrameSize()); + assertEquals(2048, cfg.getMaxMessageSize()); + assertEquals(Timeout.ofSeconds(3), cfg.getConnectTimeout()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java new file mode 100644 index 0000000000..5e233d0ef9 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java @@ -0,0 +1,344 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.TimeValue; +import org.junit.jupiter.api.Test; + +final class WebSocketClientTest { + + private static final class NoNetworkClient extends CloseableWebSocketClient { + + @Override + public void start() { + // no-op + } + + @Override + public IOReactorStatus getStatus() { + return IOReactorStatus.ACTIVE; + } + + @Override + public void awaitShutdown(final TimeValue waitTime) { + // no-op + } + + @Override + public void initiateShutdown() { + // no-op + } + + // ModalCloseable (if your ModalCloseable declares this) + public void close(final CloseMode closeMode) { + // no-op + } + + // Closeable + @Override + public void close() { + // no-op – needed for try-with-resources + } + + @Override + protected CompletableFuture doConnect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + + final CompletableFuture f = new CompletableFuture<>(); + final LocalLoopWebSocket ws = new LocalLoopWebSocket(listener, cfg); + try { + listener.onOpen(ws); + } catch (final Throwable ignore) { + } + f.complete(ws); + return f; + } + } + + private static final class LocalLoopWebSocket implements WebSocket { + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private volatile boolean open = true; + + LocalLoopWebSocket(final WebSocketListener listener, final WebSocketClientConfig cfg) { + this.listener = listener; + this.cfg = cfg != null ? cfg : WebSocketClientConfig.custom().build(); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (!open) { + return false; + } + if (cfg.getMaxMessageSize() > 0 && data != null && data.length() > cfg.getMaxMessageSize()) { + // Simulate client closing due to oversized message + try { + listener.onClose(1009, "Message too big"); + } catch (final Throwable ignore) { + } + open = false; + return false; + } + try { + final CharBuffer cb = data != null ? CharBuffer.wrap(data) : CharBuffer.allocate(0); + listener.onText(cb, finalFragment); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (!open) { + return false; + } + try { + listener.onBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0), finalFragment); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean ping(final ByteBuffer data) { + if (!open) { + return false; + } + try { + listener.onPong(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0)); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean pong(final ByteBuffer data) { + // In a real client this would send a PONG; here it's a no-op. + return open; + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + final CompletableFuture f = new CompletableFuture<>(); + if (!open) { + f.complete(null); + return f; + } + open = false; + try { + listener.onClose(statusCode, reason != null ? reason : ""); + } catch (final Throwable ignore) { + } + f.complete(null); + return f; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (!open) { + return false; + } + if (fragments == null || fragments.isEmpty()) { + return true; + } + for (int i = 0; i < fragments.size(); i++) { + final boolean last = i == fragments.size() - 1 && finalFragment; + if (!sendText(fragments.get(i), last)) { + return false; + } + } + return true; + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (!open) { + return false; + } + if (fragments == null || fragments.isEmpty()) { + return true; + } + for (int i = 0; i < fragments.size(); i++) { + final boolean last = i == fragments.size() - 1 && finalFragment; + if (!sendBinary(fragments.get(i), last)) { + return false; + } + } + return true; + } + } + + private static CloseableWebSocketClient newClient() { + final CloseableWebSocketClient c = new NoNetworkClient(); + c.start(); + return c; + } + + // ------------------------------- Tests ----------------------------------- + + @Test + void echo_uncompressed_no_network() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final StringBuilder echoed = new StringBuilder(); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/echo"), new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + final String prefix = "hello @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16; i++) { + sb.append(prefix); + } + ws.sendText(sb, true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + ws.close(1000, "done"); + } + + @Override + public void onClose(final int code, final String reason) { + assertEquals(1000, code); + assertEquals("done", reason); + assertTrue(echoed.length() > 0); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(3, TimeUnit.SECONDS)); + } + } + + @Test + void ping_interleaved_fragmentation_no_network() throws Exception { + final CountDownLatch gotText = new CountDownLatch(1); + final CountDownLatch gotPong = new CountDownLatch(1); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/interleave"), new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + ws.ping(StandardCharsets.UTF_8.encode("ping")); + ws.sendText("hello", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + gotText.countDown(); + } + + @Override + public void onPong(final ByteBuffer payload) { + gotPong.countDown(); + } + }, cfg, null); + + assertTrue(gotPong.await(2, TimeUnit.SECONDS)); + assertTrue(gotText.await(2, TimeUnit.SECONDS)); + } + } + + @Test + void max_message_1009_no_network() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final int maxMessage = 2048; + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setMaxMessageSize(maxMessage) + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/echo"), new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + final StringBuilder sb = new StringBuilder(); + final String chunk = "1234567890abcdef-"; + while (sb.length() <= maxMessage * 2) { + sb.append(chunk); + } + ws.sendText(sb, true); + } + + @Override + public void onClose(final int code, final String reason) { + assertEquals(1009, code); + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java new file mode 100644 index 0000000000..153a632cdd --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java @@ -0,0 +1,106 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.junit.jupiter.api.Test; + +final class Http1UpgradeProtocolExtensionTest { + + @Test + void pmce_rejectedWhenDisabled() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate")); + } + + @Test + void pmce_rejectedWhenParametersNotOffered() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerServerNoContextTakeover(false) + .offerClientNoContextTakeover(false) + .offerClientMaxWindowBits(null) + .offerServerMaxWindowBits(null) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; server_no_context_takeover")); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=15")); + } + + @Test + void pmce_rejectedOnUnknownOrDuplicate() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; unknown=1")); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate, permessage-deflate")); + } + + @Test + void pmce_rejectedOnUnsupportedClientWindowBits() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerClientMaxWindowBits(15) + .offerServerMaxWindowBits(15) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=12")); + } + + @Test + void pmce_acceptsServerWindowBitsBelow15() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerServerMaxWindowBits(15) + .build(); + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, + "permessage-deflate; server_max_window_bits=12"); + assertFalse(chain.isEmpty()); + assertEquals(FrameHeaderBits.RSV1, chain.rsvMask()); + } + + @Test + void pmce_validNegotiation_buildsChain() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerClientMaxWindowBits(15) + .offerServerMaxWindowBits(15) + .offerClientNoContextTakeover(true) + .offerServerNoContextTakeover(true) + .build(); + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, + "permessage-deflate; client_no_context_takeover; server_no_context_takeover; client_max_window_bits=15"); + assertFalse(chain.isEmpty()); + assertEquals(FrameHeaderBits.RSV1, chain.rsvMask()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java new file mode 100644 index 0000000000..34a8560ca1 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java @@ -0,0 +1,125 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +public final class WebSocketEchoClient { + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "ws://localhost:8080/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(true) + .offerServerNoContextTakeover(true) + .offerClientNoContextTakeover(true) + .offerClientMaxWindowBits(15) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + System.out.println("[TEST] connecting: " + uri); + client.start(); + + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + System.out.println("[TEST] open: " + uri); + + final String prefix = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 256; i++) { + sb.append(prefix); + } + final String msg = sb.toString(); + + ws.sendText(msg, true); + System.out.println("[TEST] sent (chars=" + msg.length() + ")"); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + final int len = text.length(); + final CharSequence preview = len > 120 ? text.subSequence(0, 120) + "…" : text; + System.out.println("[TEST] text (chars=" + len + "): " + preview); + ws.close(1000, "done"); + } + + @Override + public void onPong(final ByteBuffer payload) { + System.out.println("[TEST] pong: " + StandardCharsets.UTF_8.decode(payload)); + } + + @Override + public void onClose(final int code, final String reason) { + System.out.println("[TEST] close: " + code + " " + reason); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + ex.printStackTrace(System.err); + done.countDown(); + } + }, cfg).exceptionally(ex -> { + ex.printStackTrace(System.err); + done.countDown(); + return null; + }); + + if (!done.await(12, TimeUnit.SECONDS)) { + System.err.println("[TEST] Timed out waiting for echo/close"); + System.exit(1); + } + + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} + diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java new file mode 100644 index 0000000000..15a78252a9 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java @@ -0,0 +1,131 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; + +/** + *

WebSocketEchoServer

+ * + *

A tiny WebSocket echo server built on httpcore5-websocket. It echoes back + * any TEXT or BINARY message it receives. This is intended for local + * development and interoperability testing of {@code WebSocketClient} and is + * not production hardened.

+ * + *

Usage

+ *
+ *   # Default port 8080
+ *   java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer
+ *
+ *   # Custom port
+ *   java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer 9090
+ * 
+ * + *

Once started, the server listens on {@code ws://localhost:<port>/echo}.

+ */ +public final class WebSocketEchoServer { + + private WebSocketEchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final CountDownLatch shutdown = new CountDownLatch(1); + + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onOpen(final WebSocketSession session) { + System.out.println("WebSocket open: " + session.getRemoteAddress()); + } + + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int statusCode, final String reason) { + System.out.println("WebSocket close: " + statusCode + " " + reason); + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + System.err.println("WebSocket error: " + cause.getMessage()); + cause.printStackTrace(System.err); + } + + @Override + public String selectSubprotocol(final List protocols) { + return protocols.isEmpty() ? null : protocols.get(0); + } + }) + .create(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.initiateShutdown(); + server.stop(); + shutdown.countDown(); + })); + + server.start(); + System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo"); + shutdown.await(); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java new file mode 100644 index 0000000000..69c3c1afd7 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +/** + * Standalone H2 WebSocket echo client (RFC 8441). + */ +public final class WebSocketH2EchoClient { + + private WebSocketH2EchoClient() { + } + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "ws://localhost:8080/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + System.out.println("[H2] echo: " + text); + ws.close(1000, "done"); + } + + @Override + public void onBinary(final ByteBuffer payload, final boolean last) { + System.out.println("[H2] binary: " + payload.remaining()); + } + + @Override + public void onClose(final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + if (!done.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for H2 echo"); + } + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java new file mode 100644 index 0000000000..6b96e434b3 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java @@ -0,0 +1,106 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; + +/** + * Standalone H2 WebSocket echo server (RFC 8441). + */ +public final class WebSocketH2EchoServer { + + private WebSocketH2EchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final CountDownLatch done = new CountDownLatch(1); + + final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + cause.printStackTrace(System.err); + done.countDown(); + } + }) + .create(); + + server.start(); + System.out.println("[H2] echo server started at ws://localhost:" + server.getLocalPort() + "/echo"); + + try { + done.await(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + } finally { + server.initiateShutdown(); + server.stop(); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java new file mode 100644 index 0000000000..414bc2200b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java @@ -0,0 +1,123 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http2.ssl.H2ClientTlsStrategy; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +/** + * Standalone H2 WebSocket echo client over TLS (RFC 8441, wss://). + */ +public final class WebSocketH2TlsEchoClient { + + private WebSocketH2TlsEchoClient() { + } + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "wss://localhost:8443/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(WebSocketH2TlsEchoClient.class.getResource("/test.keystore"), + "nopassword".toCharArray()) + .build(); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .setTlsStrategy(new H2ClientTlsStrategy(sslContext)) + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2-tls", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + System.out.println("[H2/TLS] echo: " + text); + ws.close(1000, "done"); + } + + @Override + public void onBinary(final ByteBuffer payload, final boolean last) { + System.out.println("[H2/TLS] binary: " + payload.remaining()); + } + + @Override + public void onClose(final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + if (!done.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for H2/TLS echo"); + } + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java new file mode 100644 index 0000000000..9d0852d1ac --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java @@ -0,0 +1,117 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.core5.http2.ssl.H2ServerTlsStrategy; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; + +/** + * Standalone H2 WebSocket echo server over TLS (RFC 8441, wss://). + */ +public final class WebSocketH2TlsEchoServer { + + private WebSocketH2TlsEchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8443; + final CountDownLatch done = new CountDownLatch(1); + + final SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"), + "nopassword".toCharArray()) + .loadKeyMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"), + "nopassword".toCharArray(), "nopassword".toCharArray()) + .build(); + + final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .setTlsStrategy(new H2ServerTlsStrategy(sslContext)) + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final java.io.IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final java.io.IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final java.io.IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + cause.printStackTrace(System.err); + done.countDown(); + } + }) + .create(); + + server.start(); + System.out.println("[H2/TLS] echo server started at wss://localhost:" + server.getLocalPort() + "/echo"); + + try { + done.await(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + } finally { + server.initiateShutdown(); + server.stop(); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java new file mode 100644 index 0000000000..e16e0c7f2b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java @@ -0,0 +1,149 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.junit.jupiter.api.Test; + +class WsDecoderTest { + + @Test + void serverMaskedFrame_isRejected() { + // Build a minimal TEXT frame with MASK bit set (which servers MUST NOT set). + // 0x81 FIN|TEXT, 0x80 | 0 = mask + length 0, then 4-byte masking key. + final ByteBuffer buf = ByteBuffer.allocate(2 + 4); + buf.put((byte) 0x81); + buf.put((byte) 0x80); // MASK set, len=0 + buf.putInt(0x11223344); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void controlFrame_fragmented_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0x09); // FIN=0, PING + buf.put((byte) 0x00); // len=0 + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void controlFrame_tooLarge_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(4); + buf.put((byte) 0x89); // FIN=1, PING + buf.put((byte) 126); // len=126 (invalid for control frame) + buf.putShort((short) 126); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void rsvBitsWithoutExtensions_areRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0xC1); // FIN=1, RSV1=1, TEXT + buf.put((byte) 0x00); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void reservedOpcode_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0x83); // FIN=1, opcode=3 (reserved) + buf.put((byte) 0x00); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void extendedLen_126_and_127_parse() { + // A FIN|BINARY with 126 length, len=300 + final byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + + final ByteBuffer f126 = ByteBuffer.allocate(2 + 2 + payload.length); + f126.put((byte) 0x82); // FIN+BINARY + f126.put((byte) 126); + f126.putShort((short) payload.length); + f126.put(payload); + f126.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096); + assertTrue(d.decode(f126)); + assertEquals(FrameOpcode.BINARY, d.opcode()); + assertEquals(payload.length, d.payload().remaining()); + + // Now 127 with len=65540 (> 0xFFFF) + final int big = 65540; + final byte[] p2 = new byte[big]; + final ByteBuffer f127 = ByteBuffer.allocate(2 + 8 + p2.length); + f127.put((byte) 0x82); + f127.put((byte) 127); + f127.putLong(big); + f127.put(p2); + f127.flip(); + + final WebSocketFrameDecoder d2 = new WebSocketFrameDecoder(big + 32); + assertTrue(d2.decode(f127)); + assertEquals(big, d2.payload().remaining()); + } + + @Test + void partialBuffer_returnsFalse_and_consumesNothing() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0x81); + f.put((byte) 0x7E); // says 126, but no length bytes present + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); + // Should mark/reset and return false; buffer remains at same position after call (no throw). + final int posBefore = f.position(); + assertFalse(d.decode(f)); + assertEquals(posBefore, f.position()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java new file mode 100644 index 0000000000..41cf9a4e66 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java @@ -0,0 +1,184 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.PerMessageDeflate; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.junit.jupiter.api.Test; + +final class WsOutboundCompressionTest { + + @Test + void outboundPmce_setsRsv1_and_roundTrips() throws Exception { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setOutgoingChunkSize(64 * 1024) + .build(); + final ExtensionChain chain = new ExtensionChain(); + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + chain.add(pmce); + + final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() { + }, cfg, chain); + final WebSocketOutbound out = new WebSocketOutbound(state); + final WebSocket ws = out.facade(); + + final String text = "hello hello hello hello hello"; + assertTrue(ws.sendText(text, true)); + + final WebSocketOutbound.OutFrame f = state.dataOutbound.poll(); + assertNotNull(f); + + final Frame frame = parseFrame(f.buf.asReadOnlyBuffer()); + assertEquals(FrameOpcode.TEXT, frame.opcode); + assertTrue(frame.rsv1); + assertTrue(frame.masked); + + final byte[] decoded = pmce.newDecoder().decode(frame.payload); + assertArrayEquals(text.getBytes(StandardCharsets.UTF_8), decoded); + + release(state, f); + } + + @Test + void outboundPmce_rsv1_onlyOnFirstFragment() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .build(); + final ExtensionChain chain = new ExtensionChain(); + chain.add(new PerMessageDeflate(true, true, true, null, null)); + + final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() { + }, cfg, chain); + final WebSocketOutbound out = new WebSocketOutbound(state); + final WebSocket ws = out.facade(); + + assertTrue(ws.sendTextBatch(Arrays.asList("alpha", "beta"), true)); + + final WebSocketOutbound.OutFrame first = state.dataOutbound.poll(); + final WebSocketOutbound.OutFrame second = state.dataOutbound.poll(); + assertNotNull(first); + assertNotNull(second); + + final Frame f1 = parseFrame(first.buf.asReadOnlyBuffer()); + final Frame f2 = parseFrame(second.buf.asReadOnlyBuffer()); + + assertEquals(FrameOpcode.TEXT, f1.opcode); + assertTrue(f1.rsv1); + assertEquals(FrameOpcode.CONT, f2.opcode); + assertFalse(f2.rsv1); + + release(state, first); + release(state, second); + } + + private static void release(final WebSocketSessionState state, final WebSocketOutbound.OutFrame f) { + if (f.pooled) { + state.bufferPool.release(f.buf); + } + } + + private static ProtocolIOSession dummySession() { + return (ProtocolIOSession) Proxy.newProxyInstance( + ProtocolIOSession.class.getClassLoader(), + new Class[]{ProtocolIOSession.class}, + (proxy, method, args) -> { + final Class rt = method.getReturnType(); + if (rt == void.class) { + return null; + } + if (rt == boolean.class) { + return false; + } + if (rt == int.class) { + return 0; + } + if (rt == long.class) { + return 0L; + } + if (rt == float.class) { + return 0f; + } + if (rt == double.class) { + return 0d; + } + return null; + }); + } + + private static Frame parseFrame(final ByteBuffer buf) { + final int b0 = buf.get() & 0xFF; + final int b1 = buf.get() & 0xFF; + long len = b1 & 0x7F; + if (len == 126) { + len = buf.getShort() & 0xFFFF; + } else if (len == 127) { + len = buf.getLong(); + } + final boolean masked = (b1 & 0x80) != 0; + final byte[] mask = masked ? new byte[4] : null; + if (mask != null) { + buf.get(mask); + } + final byte[] payload = new byte[(int) len]; + buf.get(payload); + if (mask != null) { + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (payload[i] ^ mask[i & 3]); + } + } + return new Frame(b0, masked, payload); + } + + private static final class Frame { + final int opcode; + final boolean rsv1; + final boolean masked; + final byte[] payload; + + Frame(final int b0, final boolean masked, final byte[] payload) { + this.opcode = b0 & 0x0F; + this.rsv1 = (b0 & 0x40) != 0; + this.masked = masked; + this.payload = payload; + } + } +} diff --git a/httpclient5-websocket/src/test/resources/log4j2.xml b/httpclient5-websocket/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..ce9e796abd --- /dev/null +++ b/httpclient5-websocket/src/test/resources/log4j2.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/httpclient5-websocket/src/test/resources/test.keystore b/httpclient5-websocket/src/test/resources/test.keystore new file mode 100644 index 0000000000..f8d5ace1ad Binary files /dev/null and b/httpclient5-websocket/src/test/resources/test.keystore differ diff --git a/pom.xml b/pom.xml index 1a0d7291c6..d3c57a0baa 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ 1.8 1.8 - 5.4 + 5.5-alpha1-SNAPSHOT 2.25.3 1.20.0 2.5.2 @@ -85,6 +85,7 @@ 1.58.0 1.26.2 2.9.3 + 9.4.54.v20240208 @@ -99,6 +100,11 @@ httpcore5-h2 ${httpcore.version} + + org.apache.httpcomponents.core5 + httpcore5-websocket + ${httpcore.version} + org.apache.httpcomponents.core5 httpcore5-testing @@ -135,6 +141,11 @@ httpclient5-sse ${project.version} + + org.apache.httpcomponents.client5 + httpclient5-websocket + ${project.version} + org.slf4j slf4j-api @@ -265,6 +276,22 @@ caffeine ${caffeine.version} + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + + + org.eclipse.jetty.websocket + websocket-server + ${jetty.version} + + + org.eclipse.jetty + jetty-server + ${jetty.version} + test + @@ -273,6 +300,7 @@ httpclient5-sse httpclient5-observation httpclient5-fluent + httpclient5-websocket httpclient5-cache httpclient5-testing @@ -495,6 +523,10 @@ Apache HttpClient SSE org.apache.hc.client5.http.sse* + + Apache HttpClient SSE + org.apache.hc.client5.http.websocket* +