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..b8aa036d56 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java @@ -0,0 +1,413 @@ +/* + * ==================================================================== + * 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.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.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; + + @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 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 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() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + ws.ping(null); + this.ws = ws; + 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 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..bd904b0e9c --- /dev/null +++ b/httpclient5-websocket/pom.xml @@ -0,0 +1,119 @@ + + + 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.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..c07dce42da --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * 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}.

+ * + * @since 5.6 + */ +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..d7aeb0c6ae --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java @@ -0,0 +1,573 @@ +/* + * ==================================================================== + * 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.

+ * + * @since 5.6 + */ +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; + + // 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) { + + 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; + } + + /** + * 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.

+ * + * @return offered {@code client_max_window_bits}, or {@code null} if not offered + * @since 5.6 + */ + 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.

+ * + * @return offered {@code server_max_window_bits}, or {@code null} if not offered + * @since 5.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + public int getMaxFramesPerTick() { + return maxFramesPerTick; + } + + /** + * Capacity of the internal buffer pool used by WebSocket I/O. + * + * @return pool capacity (must be > 0) + * @since 5.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + public int getMaxOutboundControlQueue() { + return maxOutboundControlQueue; + } + + /** + * Creates a new builder instance with default settings. + * + * @return builder + * @since 5.6 + */ + public static Builder custom() { + return new Builder(); + } + + /** + * Builder for {@link WebSocketClientConfig}. + * + *

The builder is mutable and not thread-safe.

+ * + * @since 5.6 + */ + 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; + + /** + * 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.6 + */ + 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.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + 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.6 + */ + public Builder setMaxOutboundControlQueue(final int v) { + this.maxOutboundControlQueue = v; + return this; + } + + /** + * Builds an immutable {@link WebSocketClientConfig}. + * + * @return configuration instance + * @throws IllegalArgumentException if any parameter is invalid + * @since 5.6 + */ + 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 + ); + } + } +} \ 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..264478a12e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java @@ -0,0 +1,99 @@ +/* + * ==================================================================== + * 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.

+ * + * @since 5.6 + */ +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..921f689da2 --- /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.6 + */ +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..2348eef60e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.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.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.

+ * + * @since 5.6 + */ +@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..4d00bbb908 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.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 + * . + * + */ +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. + * + * @since 5.6 + */ +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.6 + */ + 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..7dd3326813 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java @@ -0,0 +1,446 @@ +/* + * ==================================================================== + * 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.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.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.

+ * + * @since 5.6 + */ +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 org.apache.hc.core5.http.impl.Http1StreamListener streamListener; + 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 org.apache.hc.core5.http.impl.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 + ); + + final ThreadFactory tf = threadFactory != null + ? threadFactory + : new DefaultThreadFactory("websocket-main", true); + + return new DefaultWebSocketClient( + requester, + connPool, + defaultConfig, + tf + ); + } +} \ 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..05f78f20c0 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java @@ -0,0 +1,78 @@ +/* + * ==================================================================== + * 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.

+ * + * @since 5.6 + */ +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..91b82027a1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java @@ -0,0 +1,107 @@ +/* + * ==================================================================== + * 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.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 HttpAsyncRequester requester; + private final ExecutorService executorService; + private final AtomicReference status; + + AbstractWebSocketClient(final HttpAsyncRequester requester, final ThreadFactory threadFactory) { + super(); + this.requester = Args.notNull(requester, "requester"); + 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(requester::start); + } + } + + boolean isRunning() { + return status.get() == Status.RUNNING; + } + + @Override + public final IOReactorStatus getStatus() { + return requester.getStatus(); + } + + @Override + public final void awaitShutdown(final TimeValue waitTime) throws InterruptedException { + requester.awaitShutdown(waitTime); + } + + @Override + public final void initiateShutdown() { + if (LOG.isDebugEnabled()) { + LOG.debug("Initiating shutdown"); + } + requester.initiateShutdown(); + } + + void internalClose(final CloseMode closeMode) { + } + + @Override + public final void close(final CloseMode closeMode) { + if (LOG.isDebugEnabled()) { + LOG.debug("Shutdown {}", closeMode); + } + 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..9671f31040 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java @@ -0,0 +1,51 @@ +/* + * ==================================================================== + * 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.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) { + super(requester, connPool, defaultConfig, threadFactory); + } +} 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..26ba8a9362 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java @@ -0,0 +1,104 @@ +/* + * ==================================================================== + * 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.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.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; + + InternalWebSocketClientBase( + final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketClientConfig defaultConfig, + final ThreadFactory threadFactory) { + super(Args.notNull(requester, "requester"), threadFactory); + this.connPool = Args.notNull(connPool, "connPool"); + this.defaultConfig = defaultConfig != null ? defaultConfig : WebSocketClientConfig.custom().build(); + this.h1 = newH1Protocol(requester, connPool); + } + + /** + * HTTP/1.1 Upgrade protocol. + */ + protected WebSocketProtocolStrategy newH1Protocol( + final HttpAsyncRequester requester, + final ManagedConnPool connPool) { + return new Http1UpgradeProtocol(requester, connPool); + } + + @Override + protected CompletableFuture doConnect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfgOrNull, + final HttpContext context) { + + final WebSocketClientConfig cfg = cfgOrNull != null ? cfgOrNull : defaultConfig; + 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..9e2344efc0 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java @@ -0,0 +1,214 @@ +/* + * ==================================================================== + * 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.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.6 + */ +@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 org.apache.hc.core5.http.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..0459103ed6 --- /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.6 + */ +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..3f90b17b63 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.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.logging; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.function.Callback; +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) { + 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..6269ae5059 --- /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.6 + */ +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..45dba4e38e --- /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.6 + */ +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..c80c0fbda9 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.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.client.impl.protocol; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +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.client5.http.websocket.core.extension.ExtensionChain; +import org.apache.hc.client5.http.websocket.core.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 org.apache.hc.core5.http.impl.bootstrap.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 org.apache.hc.core5.http.protocol.HttpContext hc) + throws java.io.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 java.util.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 = new ExtensionChain(); + final String ext = headerValue(response, "Sec-WebSocket-Extensions"); + if (ext != null && !ext.isEmpty()) { + boolean pmceSeen = false, serverNoCtx = false, clientNoCtx = false; + Integer clientBits = null, serverBits = null; + + 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); + } + 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)) { + serverNoCtx = true; + } else if ("client_no_context_takeover".equalsIgnoreCase(p)) { + clientNoCtx = true; + } + } 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)) { + try { + 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)) { + try { + 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); + } + } + } + } + } + + if (pmceSeen) { + if (!cfg.isPerMessageDeflateEnabled()) { + throw new IllegalStateException("Server negotiated PMCE but client disabled it"); + } + chain.add(new PerMessageDeflate(true, serverNoCtx, clientNoCtx, clientBits, serverBits)); + } + } + + 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 java.util.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 java.util.Base64.getEncoder().encodeToString(sha1.digest()); + } +} 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..7b8907840b --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java @@ -0,0 +1,64 @@ +/* + * ==================================================================== + * 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; + +/** + * 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); + } + } + + public Http2ExtendedConnectProtocol() { + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new H2NotAvailable("HTTP/2 Extended CONNECT not wired yet")); + return f; + } +} 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..7dad310f16 --- /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.6 + */ +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..4904ac12f2 --- /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.6 + */ +package org.apache.hc.client5.http.websocket.client; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java new file mode 100644 index 0000000000..c1af69a9a6 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * 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.core.exceptions; + + +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class WebSocketProtocolException extends RuntimeException { + + public final int closeCode; + + public WebSocketProtocolException(final int closeCode, final String message) { + super(message); + this.closeCode = closeCode; + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java new file mode 100644 index 0000000000..a7c7728b38 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/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.6 + */ +package org.apache.hc.client5.http.websocket.core.exceptions; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java new file mode 100644 index 0000000000..2360711268 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java @@ -0,0 +1,124 @@ +/* + * ==================================================================== + * 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.core.extension; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Simple single-step chain; if multiple extensions are added they are applied in order. + * Only the FIRST extension can contribute the RSV bit (RSV1 in practice). + */ +@Internal +public final class ExtensionChain { + private final List exts = new ArrayList<>(); + + public void add(final WebSocketExtensionChain e) { + if (e != null) { + exts.add(e); + } + } + + public boolean isEmpty() { + return exts.isEmpty(); + } + + /** + * App-thread encoder chain. + */ + public EncodeChain newEncodeChain() { + final List encs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + encs.add(e.newEncoder()); + } + return new EncodeChain(encs); + } + + /** + * I/O-thread decoder chain. + */ + public DecodeChain newDecodeChain() { + final List decs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + decs.add(e.newDecoder()); + } + return new DecodeChain(decs); + } + + // ---------------------- + + public static final class EncodeChain { + private final List encs; + + public EncodeChain(final List encs) { + this.encs = encs; + } + + /** + * Encode one fragment through the chain; note RSV flag for the first extension. + * Returns {@link WebSocketExtensionChain.Encoded}. + */ + public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) { + if (encs.isEmpty()) { + return new WebSocketExtensionChain.Encoded(data, false); + } + byte[] out = data; + boolean setRsv1 = false; + boolean firstExt = true; + for (final WebSocketExtensionChain.Encoder e : encs) { + final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin); + out = res.payload; + if (first && firstExt && res.setRsvOnFirst) { + setRsv1 = true; + } + firstExt = false; + } + return new WebSocketExtensionChain.Encoded(out, setRsv1); + } + } + + public static final class DecodeChain { + private final List decs; + + public DecodeChain(final List decs) { + this.decs = decs; + } + + /** + * Decode a full message (reverse order if stacking). + */ + public byte[] decode(final byte[] data) throws Exception { + byte[] out = data; + for (int i = decs.size() - 1; i >= 0; i--) { + out = decs.get(i).decode(out); + } + return out; + } + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java new file mode 100644 index 0000000000..9d9fb155d0 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java @@ -0,0 +1,186 @@ +/* + * ==================================================================== + * 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.core.extension; + +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits; +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class PerMessageDeflate implements WebSocketExtensionChain { + private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF}; + + private final boolean enabled; + private final boolean serverNoContextTakeover; + private final boolean clientNoContextTakeover; + private final Integer clientMaxWindowBits; // negotiated or null + private final Integer serverMaxWindowBits; // negotiated or null + + public PerMessageDeflate(final boolean enabled, + final boolean serverNoContextTakeover, + final boolean clientNoContextTakeover, + final Integer clientMaxWindowBits, + final Integer serverMaxWindowBits) { + this.enabled = enabled; + this.serverNoContextTakeover = serverNoContextTakeover; + this.clientNoContextTakeover = clientNoContextTakeover; + this.clientMaxWindowBits = clientMaxWindowBits; + this.serverMaxWindowBits = serverMaxWindowBits; + } + + @Override + public int rsvMask() { + return FrameHeaderBits.RSV1; + } + + @Override + public Encoder newEncoder() { + if (!enabled) { + return (data, first, fin) -> new Encoded(data, false); + } + return new Encoder() { + private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE + + @Override + public Encoded encode(final byte[] data, final boolean first, final boolean fin) { + final byte[] out = first && fin + ? compressMessage(data) + : compressFragment(data, fin); + // RSV1 on first compressed data frame only + return new Encoded(out, first); + } + + private byte[] compressMessage(final byte[] data) { + return doDeflate(data, true, true, clientNoContextTakeover); + } + + private byte[] compressFragment(final byte[] data, final boolean fin) { + return doDeflate(data, fin, true,fin && clientNoContextTakeover); + } + + private byte[] doDeflate(final byte[] data, + final boolean fin, + final boolean stripTail, + final boolean maybeReset) { + if (data == null || data.length == 0) { + if (fin && maybeReset) { + def.reset(); + } + return new byte[0]; + } + def.setInput(data); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2)); + final byte[] buf = new byte[8192]; + while (!def.needsInput()) { + final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + byte[] all = out.toByteArray(); + if (stripTail && all.length >= 4) { + final int newLen = all.length - 4; // strip 00 00 FF FF + if (newLen <= 0) { + all = new byte[0]; + } else { + final byte[] trimmed = new byte[newLen]; + System.arraycopy(all, 0, trimmed, 0, newLen); + all = trimmed; + } + } + if (fin && maybeReset) { + def.reset(); + } + return all; + } + }; + } + + @Override + public Decoder newDecoder() { + if (!enabled) { + return payload -> payload; + } + return new Decoder() { + private final Inflater inf = new Inflater(true); + + @Override + public byte[] decode(final byte[] compressedMessage) throws Exception { + final byte[] withTail; + if (compressedMessage == null || compressedMessage.length == 0) { + withTail = TAIL.clone(); + } else { + withTail = new byte[compressedMessage.length + 4]; + System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length); + System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4); + } + + inf.setInput(withTail); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2)); + final byte[] buf = new byte[8192]; + while (!inf.needsInput()) { + final int n = inf.inflate(buf); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + if (serverNoContextTakeover) { + inf.reset(); + } + return out.toByteArray(); + } + }; + } + + // optional getters for logging/tests + public boolean isEnabled() { + return enabled; + } + + public boolean isServerNoContextTakeover() { + return serverNoContextTakeover; + } + + public boolean isClientNoContextTakeover() { + return clientNoContextTakeover; + } + + public Integer getClientMaxWindowBits() { + return clientMaxWindowBits; + } + + public Integer getServerMaxWindowBits() { + return serverMaxWindowBits; + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java new file mode 100644 index 0000000000..977014f18d --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * 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.core.extension; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Generic extension hook for payload transform (e.g., permessage-deflate). + * Implementations may return RSV mask (usually RSV1) and indicate whether + * the first frame of a message should set RSV. + */ +@Internal +public interface WebSocketExtensionChain { + + /** + * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1). + */ + int rsvMask(); + + /** + * Create a thread-confined encoder instance (app thread). + */ + Encoder newEncoder(); + + /** + * Create a thread-confined decoder instance (I/O thread). + */ + Decoder newDecoder(); + + /** + * Encoded fragment result. + */ + final class Encoded { + public final byte[] payload; + public final boolean setRsvOnFirst; + + public Encoded(final byte[] payload, final boolean setRsvOnFirst) { + this.payload = payload; + this.setRsvOnFirst = setRsvOnFirst; + } + } + + interface Encoder { + /** + * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame. + */ + Encoded encode(byte[] data, boolean first, boolean fin); + } + + interface Decoder { + /** + * Decode a full message produced with this extension. + */ + byte[] decode(byte[] payload) throws Exception; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java new file mode 100644 index 0000000000..b2cf046271 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/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 + * . + * + */ + +/** + * WebSocket extension SPI and implementations. + * + *

Includes the generic {@code Extension} SPI, chaining support, and a + * client-side permessage-deflate (RFC 7692) implementation.

+ * + * @since 5.6 + */ +package org.apache.hc.client5.http.websocket.core.extension; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java new file mode 100644 index 0000000000..9e76108f55 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java @@ -0,0 +1,49 @@ +/* + * ==================================================================== + * 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.core.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * WebSocket frame header bit masks (RFC 6455 §5.2). + */ +@Internal +public final class FrameHeaderBits { + private FrameHeaderBits() { + } + + // First header byte + public static final int FIN = 0x80; + public static final int RSV1 = 0x40; + public static final int RSV2 = 0x20; + public static final int RSV3 = 0x10; + // low 4 bits (0x0F) are opcode + + // Second header byte + public static final int MASK_BIT = 0x80; // client->server payload mask bit + // low 7 bits (0x7F) are payload len indicator +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java new file mode 100644 index 0000000000..524cffc33d --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java @@ -0,0 +1,88 @@ +/* + * ==================================================================== + * 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.core.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * RFC 6455 opcode constants + helpers. + */ +@Internal +public final class FrameOpcode { + public static final int CONT = 0x0; + public static final int TEXT = 0x1; + public static final int BINARY = 0x2; + public static final int CLOSE = 0x8; + public static final int PING = 0x9; + public static final int PONG = 0xA; + + private FrameOpcode() { + } + + /** + * Control frames have the high bit set in the low nibble (0x8–0xF). + */ + public static boolean isControl(final int opcode) { + return (opcode & 0x08) != 0; + } + + /** + * Data opcodes (not continuation). + */ + public static boolean isData(final int opcode) { + return opcode == TEXT || opcode == BINARY; + } + + /** + * Continuation opcode. + */ + public static boolean isContinuation(final int opcode) { + return opcode == CONT; + } + + /** + * Optional: human-readable name for debugging. + */ + public static String name(final int opcode) { + switch (opcode) { + case CONT: + return "CONT"; + case TEXT: + return "TEXT"; + case BINARY: + return "BINARY"; + case CLOSE: + return "CLOSE"; + case PING: + return "PING"; + case PONG: + return "PONG"; + default: + return "0x" + Integer.toHexString(opcode); + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java new file mode 100644 index 0000000000..40255ae95a --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java @@ -0,0 +1,189 @@ +/* + * ==================================================================== + * 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.core.frame; + +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.FIN; +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.MASK_BIT; +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1; +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV2; +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV3; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +import org.apache.hc.client5.http.websocket.core.message.CloseCodec; +import org.apache.hc.core5.annotation.Internal; + +/** + * RFC 6455 frame writer with helpers to build into an existing target buffer. + * + * @since 5.6 + */ +@Internal +public final class WebSocketFrameWriter { + + // -- Text/Binary ----------------------------------------------------------- + + public ByteBuffer text(final CharSequence data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(data.toString()); + // Client → server MUST be masked + return frame(FrameOpcode.TEXT, payload, fin, true); + } + + public ByteBuffer binary(final ByteBuffer data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + return frame(FrameOpcode.BINARY, payload, fin, true); + } + + // -- Control frames (FIN=true, payload ≤ 125, never compressed) ----------- + + public ByteBuffer ping(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PING payload > 125 bytes"); + } + return frame(FrameOpcode.PING, p, true, true); + } + + public ByteBuffer pong(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PONG payload > 125 bytes"); + } + return frame(FrameOpcode.PONG, p, true, true); + } + + public ByteBuffer close(final int code, final String reason) { + if (!CloseCodec.isValidToSend(code)) { + throw new IllegalArgumentException("Invalid close code to send: " + code); + } + final String safeReason = CloseCodec.truncateReasonUtf8(reason); + final ByteBuffer reasonBuf = safeReason.isEmpty() + ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(safeReason); + + if (reasonBuf.remaining() > 123) { + throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)"); + } + + final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining()); + p.put((byte) (code >> 8 & 0xFF)); + p.put((byte) (code & 0xFF)); + if (reasonBuf.hasRemaining()) { + p.put(reasonBuf); + } + p.flip(); + return frame(FrameOpcode.CLOSE, p, true, true); + } + + public ByteBuffer closeEcho(final ByteBuffer payload) { + final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("Close payload > 125 bytes"); + } + return frame(FrameOpcode.CLOSE, p, true, true); + } + + // -- Core framing ---------------------------------------------------------- + + public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) { + return frameWithRSV(opcode, payload, fin, mask, 0); + } + + public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits) { + final int len = payload == null ? 0 : payload.remaining(); + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final int maskLen = mask ? 4 : 0; + final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN); + frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out); + out.flip(); + return out; + } + + public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final ByteBuffer out) { + return frameIntoWithRSV(opcode, payload, fin, mask, 0, out); + } + + public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits, final ByteBuffer out) { + final int len = payload == null ? 0 : payload.remaining(); + + if (FrameOpcode.isControl(opcode)) { + if (!fin) { + throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)"); + } + if (len > 125) { + throw new IllegalArgumentException("Control frame payload > 125 bytes"); + } + if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) { + throw new IllegalArgumentException("RSV bits must be 0 on control frames"); + } + } + + final int finBit = fin ? FIN : 0; + out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F)); + + if (len <= 125) { + out.put((byte) ((mask ? MASK_BIT : 0) | len)); + } else if (len <= 0xFFFF) { + out.put((byte) ((mask ? MASK_BIT : 0) | 126)); + out.putShort((short) len); + } else { + out.put((byte) ((mask ? MASK_BIT : 0) | 127)); + out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL); + } + + int[] mkey = null; + if (mask) { + mkey = new int[]{rnd(), rnd(), rnd(), rnd()}; + out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]); + } + + if (len > 0) { + final ByteBuffer src = payload.asReadOnlyBuffer(); + int i = 0; // simpler, safer mask index + while (src.hasRemaining()) { + int b = src.get() & 0xFF; + if (mask) { + b ^= mkey[i & 3]; + i++; + } + out.put((byte) b); + } + } + return out; + } + + private static int rnd() { + return ThreadLocalRandom.current().nextInt(256); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java new file mode 100644 index 0000000000..10191101ed --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/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 + * . + * + */ + +/** + * Low-level WebSocket frame helpers. + * + *

Opcode constants, header bit masks, and frame writer utilities aligned + * with RFC 6455.

+ * + * @since 5.6 + */ +package org.apache.hc.client5.http.websocket.core.frame; \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java new file mode 100644 index 0000000000..f0c5c6a42e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java @@ -0,0 +1,190 @@ +/* + * ==================================================================== + * 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.core.message; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Helpers for RFC6455 CLOSE parsing & validation. + */ +@Internal +public final class CloseCodec { + + private CloseCodec() { + } + + + /** + * Reads the close status code from the payload buffer, if present. + * Returns {@code 1005} (“no status code present”) when the payload + * does not contain at least two bytes. + */ + public static int readCloseCode(final ByteBuffer payloadRO) { + if (payloadRO == null || payloadRO.remaining() < 2) { + return 1005; // “no status code present” + } + final int b1 = payloadRO.get() & 0xFF; + final int b2 = payloadRO.get() & 0xFF; + return (b1 << 8) | b2; + } + + /** + * Reads the close reason from the remaining bytes of the payload + * as UTF-8. Returns an empty string if there is no payload left. + */ + public static String readCloseReason(final ByteBuffer payloadRO) { + if (payloadRO == null || !payloadRO.hasRemaining()) { + return ""; + } + final ByteBuffer dup = payloadRO.slice(); + return StandardCharsets.UTF_8.decode(dup).toString(); + } + + // ---- RFC validation (sender & receiver) --------------------------------- + + /** + * RFC 6455 §7.4.2: MUST NOT appear on the wire. + */ + private static boolean isForbiddenOnWire(final int code) { + return code == 1005 || code == 1006 || code == 1015; + } + + /** + * Codes defined by RFC 6455 to send (and likewise valid to receive). + */ + private static boolean isRfcDefined(final int code) { + switch (code) { + case 1000: // normal + case 1001: // going away + case 1002: // protocol error + case 1003: // unsupported data + case 1007: // invalid payload data + case 1008: // policy violation + case 1009: // message too big + case 1010: // mandatory extension + case 1011: // internal error + return true; + default: + return false; + } + } + + /** + * Application/reserved range that may be sent by endpoints. + */ + private static boolean isAppRange(final int code) { + return code >= 3000 && code <= 4999; + } + + /** + * Validate a code we intend to PUT ON THE WIRE (sender-side). + */ + public static boolean isValidToSend(final int code) { + if (code < 0) { + return false; + } + if (isForbiddenOnWire(code)) { + return false; + } + return isRfcDefined(code) || isAppRange(code); + } + + /** + * Validate a code we PARSED FROM THE WIRE (receiver-side). + */ + public static boolean isValidToReceive(final int code) { + // 1005, 1006, 1015 must not appear on the wire + if (isForbiddenOnWire(code)) { + return false; + } + // Same allowed sets otherwise + return isRfcDefined(code) || isAppRange(code); + } + + // ---- Reason handling: max 123 bytes (2 bytes used by code) -------------- + + /** + * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points. + * This ensures that a CLOSE frame payload (2-byte status code + reason) + * never exceeds the 125-byte control frame limit. + */ + public static String truncateReasonUtf8(final String reason) { + if (reason == null || reason.isEmpty()) { + return ""; + } + final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= 123) { + return reason; + } + int i = 0; + int byteCount = 0; + while (i < reason.length()) { + final int cp = reason.codePointAt(i); + final int charCount = Character.charCount(cp); + final int extra = new String(Character.toChars(cp)) + .getBytes(StandardCharsets.UTF_8).length; + if (byteCount + extra > 123) { + break; + } + byteCount += extra; + i += charCount; + } + return reason.substring(0, i); + } + + // ---- Encoding ----------------------------------------------------------- + + /** + * Encodes a close status code and reason into a payload suitable for a + * CLOSE control frame: + * + *
+     *   payload[0] = high-byte of status code
+     *   payload[1] = low-byte of status code
+     *   payload[2..] = UTF-8 bytes of the (possibly truncated) reason
+     * 
+ * + * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the + * resulting payload never exceeds the 125-byte control frame limit. + * + * The caller is expected to have already validated the status code with + * {@link #isValidToSend(int)}. + */ + public static byte[] encode(final int statusCode, final String reason) { + final String truncated = truncateReasonUtf8(reason); + final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8); + // 2 bytes for the status code + final byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((statusCode >>> 8) & 0xFF); + payload[1] = (byte) (statusCode & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java new file mode 100644 index 0000000000..58fbede0be --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/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.6 + */ +package org.apache.hc.client5.http.websocket.core.message; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java new file mode 100644 index 0000000000..df1ab9df89 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/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 + * . + * + */ + +/** + * Core WebSocket implementation utilities. + * + *

Implementation detail packages live under {@code core}. These are not + * part of the public API and may change without notice.

+ * + * @since 5.6 + */ +package org.apache.hc.client5.http.websocket.core; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java new file mode 100644 index 0000000000..555325323e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java @@ -0,0 +1,127 @@ +/* + * ==================================================================== + * 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.core.util; + +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Lock-free fixed-size ByteBuffer pool with a hard capacity limit. + * Buffers are cleared before reuse. Non-matching capacities are dropped. + * + * @since 5.6 + */ +@Internal +public final class ByteBufferPool { + + private final ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>(); + private final AtomicInteger pooled = new AtomicInteger(0); + + private final int bufferSize; + private final int maxCapacity; + private final boolean direct; + + public ByteBufferPool(final int bufferSize, final int maxCapacity) { + this(bufferSize, maxCapacity, false); + } + + public ByteBufferPool(final int bufferSize, final int maxCapacity, final boolean direct) { + if (bufferSize <= 0 || maxCapacity < 0) { + throw new IllegalArgumentException("Invalid pool configuration"); + } + this.bufferSize = bufferSize; + this.maxCapacity = maxCapacity; + this.direct = direct; + } + + /** + * Acquire a buffer or allocate a new one if the pool is empty. + */ + public ByteBuffer acquire() { + final ByteBuffer buf = pool.poll(); + if (buf != null) { + pooled.decrementAndGet(); + buf.clear(); + return buf; + } + return direct ? ByteBuffer.allocateDirect(bufferSize) : ByteBuffer.allocate(bufferSize); + } + + /** + * Return a buffer to the pool iff it matches the configured capacity and there is room. + */ + public void release(final ByteBuffer buffer) { + if (buffer == null || buffer.capacity() != bufferSize) { + return; + } + buffer.clear(); + for (;;) { + final int n = pooled.get(); + if (n >= maxCapacity) { + return; + } + if (pooled.compareAndSet(n, n + 1)) { + pool.offer(buffer); + return; + } + } + } + + /** + * Drain the pool. + */ + public void clear() { + while (pool.poll() != null) { /* drain */ } + pooled.set(0); + } + + /** + * Size in bytes of pooled buffers. + */ + public int bufferSize() { + return bufferSize; + } + + /** + * Backwards-compatible accessor for callers expecting getBufferSize(). + */ + public int getBufferSize() { + return bufferSize; + } + + public int maxCapacity() { + return maxCapacity; + } + + public int pooledCount() { + return pooled.get(); + } + +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java new file mode 100644 index 0000000000..c3e7f67c99 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/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.6 + */ +package org.apache.hc.client5.http.websocket.core.util; 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..7f9d5d3fe5 --- /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.client5.http.websocket.core} and + * {@code org.apache.hc.client5.http.websocket.transport} are internal + * implementation details and are not intended for direct use.

+ * + * @since 5.6 + */ +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..3153ed5bab --- /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.client5.http.websocket.core.exceptions.WebSocketProtocolException; +import org.apache.hc.client5.http.websocket.core.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..ceaa5185f1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java @@ -0,0 +1,447 @@ +/* + * ==================================================================== + * 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.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException; +import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode; +import org.apache.hc.client5.http.websocket.core.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 java.util.concurrent.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 java.io.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..f45720c487 --- /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.client5.http.websocket.core.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..856f4c21eb --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java @@ -0,0 +1,486 @@ +/* + * ==================================================================== + * 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.client5.http.websocket.core.frame.FrameOpcode; +import org.apache.hc.client5.http.websocket.core.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 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) { + int currentOpcode = s.outOpcode == -1 ? opcode : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = opcode; + } + + final ByteBuffer ro = data.asReadOnlyBuffer(); + boolean ok = true; + + 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(pooledFrame(currentOpcode, slice, lastSlice))) { + ok = false; + break; + } + currentOpcode = FrameOpcode.CONT; + } + + 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; + } + + 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(pooledFrame(currentOpcode, slice, lastSlice))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + } + } + + 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; + } + + 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(pooledFrame(currentOpcode, slice, lastSlice))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + } + } + + 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; + } + } +} 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..c185a98444 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java @@ -0,0 +1,116 @@ +/* + * ==================================================================== + * 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.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.client5.http.websocket.core.extension.ExtensionChain; +import org.apache.hc.client5.http.websocket.core.frame.WebSocketFrameWriter; +import org.apache.hc.client5.http.websocket.core.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; // (not used yet for outbound compression) + final ExtensionChain.DecodeChain decChain; + + // 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; + java.io.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(); + } else { + this.encChain = null; + this.decChain = null; + } + + 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..c94f8473d7 --- /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.client5.http.websocket.core.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..07a727f485 --- /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.6 + */ +package org.apache.hc.client5.http.websocket.transport; 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/core/extension/ExtensionChainTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java new file mode 100644 index 0000000000..39408db577 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.core.extension; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class ExtensionChainTest { + + @Test + void addAndUsePmce_decodeRoundTrip() throws Exception { + final ExtensionChain chain = new ExtensionChain(); + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + chain.add(pmce); + + final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true); + final byte[] back = chain.newDecodeChain().decode(enc.payload); + + assertArrayEquals(data, back); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java new file mode 100644 index 0000000000..1436c98353 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java @@ -0,0 +1,90 @@ +/* + * ==================================================================== + * 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.core.extension; + +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; + +import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits; +import org.junit.jupiter.api.Test; + +final class MessageDeflateTest { + + @Test + void rsvMask_isRSV1() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask()); + } + + @Test + void encode_setsRSVOnlyOnFirst() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + + final byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false); + final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true); + + assertTrue(first.setRsvOnFirst, "RSV on first fragment"); + assertFalse(cont.setRsvOnFirst, "no RSV on continuation"); + assertNotEquals(0, first.payload.length); + assertNotEquals(0, cont.payload.length); + } + + @Test + void roundTrip_message() throws Exception { + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + final WebSocketExtensionChain.Decoder dec = pmce.newDecoder(); + + final String s = "The quick brown fox jumps over the lazy dog. " + + "The quick brown fox jumps over the lazy dog."; + final byte[] plain = s.getBytes(StandardCharsets.UTF_8); + + // Single-frame message: first=true, fin=true + final byte[] wire = enc.encode(plain, true, true).payload; + + assertTrue(wire.length > 0); + assertFalse(endsWithTail(wire), "tail must be stripped on wire"); + + final byte[] roundTrip = dec.decode(wire); + assertArrayEquals(plain, roundTrip); + } + + private static boolean endsWithTail(final byte[] b) { + if (b.length < 4) { + return false; + } + return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF; + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java new file mode 100644 index 0000000000..16e8a14515 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java @@ -0,0 +1,183 @@ +/* + * ==================================================================== + * 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.core.frame; + + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException; +import org.apache.hc.client5.http.websocket.transport.WebSocketFrameDecoder; +import org.junit.jupiter.api.Test; + +class FrameReaderTest { + + private static ByteBuffer serverTextFrame(final String s) { + final byte[] p = s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + final int len = p.length; + final ByteBuffer buf; + if (len <= 125) { + buf = ByteBuffer.allocate(2 + len); + buf.put((byte) 0x81); // FIN|TEXT + buf.put((byte) len); // no MASK + } else if (len <= 0xFFFF) { + buf = ByteBuffer.allocate(2 + 2 + len); + buf.put((byte) 0x81); + buf.put((byte) 126); + buf.putShort((short) len); + } else { + buf = ByteBuffer.allocate(2 + 8 + len); + buf.put((byte) 0x81); + buf.put((byte) 127); + buf.putLong(len); + } + buf.put(p); + buf.flip(); + return buf; + } + + @Test + void decode_small_text_unmasked() { + final ByteBuffer f = serverTextFrame("hello"); + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertTrue(d.decode(f)); + assertEquals(FrameOpcode.TEXT, d.opcode()); + assertTrue(d.fin()); + assertFalse(d.rsv1()); + assertEquals("hello", java.nio.charset.StandardCharsets.UTF_8.decode(d.payload()).toString()); + } + + @Test + void decode_extended_126_length() { + final byte[] p = new byte[300]; + for (int i = 0; i < p.length; i++) { + p[i] = (byte) (i & 0xFF); + } + final ByteBuffer f = ByteBuffer.allocate(2 + 2 + p.length); + f.put((byte) 0x82); // FIN|BINARY + f.put((byte) 126); + f.putShort((short) p.length); + f.put(p); + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096); + assertTrue(d.decode(f)); + assertEquals(FrameOpcode.BINARY, d.opcode()); + final ByteBuffer payload = d.payload(); + final byte[] got = new byte[p.length]; + payload.get(got); + assertArrayEquals(p, got); + } + + @Test + void decode_extended_127_length() { + final int len = 66000; + final byte[] p = new byte[len]; + Arrays.fill(p, (byte) 0xAB); + final ByteBuffer f = ByteBuffer.allocate(2 + 8 + len); + f.put((byte) 0x82); // FIN|BINARY + f.put((byte) 127); + f.putLong(len); + f.put(p); + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(len + 64); + assertTrue(d.decode(f)); + assertEquals(len, d.payload().remaining()); + } + + @Test + void masked_server_frame_is_rejected() { + // FIN|TEXT, MASK bit set, len=0, + 4-byte mask key + final ByteBuffer f = ByteBuffer.allocate(2 + 4); + f.put((byte) 0x81); + f.put((byte) 0x80); + f.putInt(0x11223344); + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); + assertThrows(WebSocketProtocolException.class, () -> d.decode(f)); + } + + @Test + void rsv_bits_without_extension_is_rejected() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0xC1); // FIN|RSV1|TEXT + f.put((byte) 0x00); // no mask, len=0 + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // strict by default + final WebSocketProtocolException ex = + assertThrows(WebSocketProtocolException.class, () -> d.decode(f)); + assertEquals(1002, ex.closeCode); + } + + @Test + void partial_buffer_returns_false_and_does_not_consume() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0x81); + f.put((byte) 0x7E); // says 126 (extended), but no length bytes present + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); + final int pos = f.position(); + assertFalse(d.decode(f)); + assertEquals(pos, f.position(), "decoder must reset position on incomplete frame"); + } + + @Test + void negative_127_length_throws() { + final ByteBuffer f = ByteBuffer.allocate(2 + 8); + f.put((byte) 0x82); + f.put((byte) 127); + f.putLong(-1L); + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); + assertThrows(WebSocketProtocolException.class, () -> d.decode(f)); + } + + @Test + void frame_too_large_throws() { + final int len = 2000; + final ByteBuffer f = ByteBuffer.allocate(2 + 2 + len); + f.put((byte) 0x82); + f.put((byte) 126); + f.putShort((short) len); + f.put(new byte[len]); + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // max frame size smaller than len + assertThrows(WebSocketProtocolException.class, () -> d.decode(f)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java new file mode 100644 index 0000000000..2e38e2d4bf --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java @@ -0,0 +1,187 @@ +/* + * ==================================================================== + * 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.core.frame; + +import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + + +class FrameWriterTest { + + private static class Parsed { + int b0; + int b1; + int opcode; + boolean fin; + boolean mask; + long len; + final byte[] maskKey = new byte[4]; + int headerLen; + ByteBuffer payloadSlice; + } + + private static Parsed parse(final ByteBuffer frame) { + final ByteBuffer frameCopy = frame.asReadOnlyBuffer(); + final Parsed r = new Parsed(); + r.b0 = frameCopy.get() & 0xFF; + r.fin = (r.b0 & 0x80) != 0; + r.opcode = r.b0 & 0x0F; + + r.b1 = frameCopy.get() & 0xFF; + r.mask = (r.b1 & 0x80) != 0; + final int low = r.b1 & 0x7F; + if (low <= 125) { + r.len = low; + } else if (low == 126) { + r.len = frameCopy.getShort() & 0xFFFF; + } else { + r.len = frameCopy.getLong(); + } + + if (r.mask) { + frameCopy.get(r.maskKey); + } + r.headerLen = frameCopy.position(); + r.payloadSlice = frameCopy.slice(); + return r; + } + + private static byte[] unmask(final Parsed p) { + final byte[] out = new byte[(int) p.len]; + for (int i = 0; i < out.length; i++) { + int b = p.payloadSlice.get(i) & 0xFF; + b ^= p.maskKey[i & 3] & 0xFF; + out[i] = (byte) b; + } + return out; + } + + @Test + void text_small_masked_roundtrip() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.text("hello", true); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue(p.mask, "client frame must be masked"); + assertEquals(5, p.len); + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void binary_len_126_masked_roundtrip() { + final byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(300, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void binary_len_127_masked_roundtrip() { + final int len = 70000; + final byte[] payload = new byte[len]; + java.util.Arrays.fill(payload, (byte) 0xA5); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(len, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void rsv1_set_with_frameWithRSV() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer payload = StandardCharsets.UTF_8.encode("x"); + // Use RSV1 bit + final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set"); + assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void close_frame_contains_code_and_reason() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.close(1000, "done"); + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertTrue(p.len >= 2); + + final byte[] raw = unmask(p); + final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF; + final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8); + + assertEquals(1000, code); + assertEquals("done", reason); + } + + @Test + void closeEcho_masks_and_preserves_payload() { + // Build a close payload manually + final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8); + final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length); + payload.put((byte) (1000 >>> 8)); + payload.put((byte) (1000 & 0xFF)); + payload.put(reason); + payload.flip(); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.closeEcho(payload); + final Parsed p = parse(f); + + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertEquals(2 + reason.length, p.len); + + final byte[] got = unmask(p); + assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF); + assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java new file mode 100644 index 0000000000..8f813af0c3 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.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.core.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class CloseCodecTest { + + @Test + void readEmptyIs1005() { + final ByteBuffer empty = ByteBuffer.allocate(0); + assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer())); + assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer())); + } + + @Test + void readCodeAndReason() { + final ByteBuffer payload = ByteBuffer.allocate(2 + 4); + payload.put((byte) 0x03).put((byte) 0xE8); // 1000 + payload.put(StandardCharsets.UTF_8.encode("done")); + payload.flip(); + + // Use the SAME buffer so the position advances + final ByteBuffer buf = payload.asReadOnlyBuffer(); + assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2 + assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only + } +} 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..cb621c16aa --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java @@ -0,0 +1,126 @@ +/* + * ==================================================================== + * 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.ofMilliseconds(200)) + .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 -> { + done.countDown(); + return null; + }); + + if (!done.await(12, TimeUnit.SECONDS)) { + System.err.println("[TEST] Timed out waiting for echo/close"); + System.exit(1); + } + + // Tidy shutdown: ask for shutdown, then wait briefly for the reactor to stop. + // Try-with-resources will still call close(GRACEFUL) at the end. + 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..d592ce6b87 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java @@ -0,0 +1,118 @@ +/* + * ==================================================================== + * 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 org.eclipse.jetty.server.Server; +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.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; + +/** + *

WebSocketEchoServer

+ * + *

A tiny embedded Jetty WebSocket server that 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.

+ * + *

Features

+ *
    + *
  • HTTP upgrade to RFC 6455 WebSocket on path {@code /echo}
  • + *
  • Echoes TEXT and BINARY frames
  • + *
  • Compatible with permessage-deflate (RFC 7692); Jetty will negotiate it if offered
  • + *
+ * + *

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}.

+ * + *

Notes

+ *
    + *
  • If the port is already in use, Jetty will fail to start with {@code BindException}.
  • + *
  • Idle timeout is set to 30 seconds for simplicity.
  • + *
+ */ +public final class WebSocketEchoServer { + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + + final Server server = new Server(port); + final ServletContextHandler ctx = new ServletContextHandler(ServletContextHandler.SESSIONS); + ctx.setContextPath("/"); + server.setHandler(ctx); + + ctx.addServlet(new ServletHolder(new EchoServlet()), "/echo"); + server.start(); + System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo"); + server.join(); + } + + /** + * Simple servlet that wires a Jetty WebSocket endpoint at {@code /echo}. + */ + public static final class EchoServlet extends WebSocketServlet { + @Override + public void configure(final WebSocketServletFactory factory) { + factory.getPolicy().setIdleTimeout(30_000); + // Jetty will negotiate permessage-deflate automatically if supported. + factory.setCreator((req, resp) -> new EchoSocket()); + } + } + + /** + * Echoes back text and binary messages. + */ + 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); + } + } + + @Override + public void onWebSocketBinary(final byte[] payload, final int off, final int len) { + final Session s = getSession(); + if (s != null && s.isOpen()) { + s.getRemote().sendBytes(ByteBuffer.wrap(payload, off, len), null); + } + } + } +} 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..ebe8eea001 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java @@ -0,0 +1,104 @@ +/* + * ==================================================================== + * 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.client5.http.websocket.core.exceptions.WebSocketProtocolException; +import org.apache.hc.client5.http.websocket.core.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 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/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/pom.xml b/pom.xml index 24e9be9aab..351e1f9d36 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,7 @@ 1.55.0 1.26.2 2.9.3 + 9.4.54.v20240208 @@ -135,6 +136,11 @@ httpclient5-sse ${project.version} + + org.apache.httpcomponents.client5 + httpclient5-websocket + ${project.version} + org.slf4j slf4j-api @@ -265,6 +271,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 +295,7 @@ httpclient5-sse httpclient5-observation httpclient5-fluent + httpclient5-websocket httpclient5-cache httpclient5-testing @@ -498,6 +521,10 @@ Apache HttpClient SSE org.apache.hc.client5.http.sse* + + Apache HttpClient SSE + org.apache.hc.client5.http.websocket* +