View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.websocket;
16  
17  import java.io.IOException;
18  import java.net.URI;
19  import java.net.URL;
20  import java.nio.ByteBuffer;
21  import java.nio.channels.AsynchronousCloseException;
22  import java.nio.channels.ClosedChannelException;
23  import java.util.ArrayList;
24  import java.util.List;
25  import java.util.concurrent.CompletableFuture;
26  import java.util.concurrent.ExecutionException;
27  
28  import org.htmlunit.WebClient;
29  import org.htmlunit.WebClientOptions;
30  import org.htmlunit.http.Cookie;
31  import org.htmlunit.jetty.client.HttpClient;
32  import org.htmlunit.jetty.http.HttpCookie;
33  import org.htmlunit.jetty.http.HttpCookieStore;
34  import org.htmlunit.jetty.util.ssl.SslContextFactory;
35  import org.htmlunit.jetty.websocket.api.Callback;
36  import org.htmlunit.jetty.websocket.api.Session;
37  import org.htmlunit.jetty.websocket.api.Session.Listener.AutoDemanding;
38  import org.htmlunit.jetty.websocket.client.WebSocketClient;
39  
40  /**
41   * Jetty12 based impl of the WebSocketAdapter.
42   * To avoid conflicts with other jetty versions used by projects, we use
43   * our own shaded version of jetty12 (https://github.com/HtmlUnit/htmlunit-websocket-client).
44   *
45   * @author Ronald Brill
46   */
47  public final class JettyWebSocketAdapter implements WebSocketAdapter {
48  
49      /**
50       * Our {@link WebSocketAdapterFactory}.
51       */
52      public static final class JettyWebSocketAdapterFactory implements WebSocketAdapterFactory {
53          /**
54           * {@inheritDoc}
55           */
56          @Override
57          public WebSocketAdapter buildWebSocketAdapter(final WebClient webClient,
58                  final WebSocketListener webSocketListener) {
59              return new JettyWebSocketAdapter(webClient, webSocketListener);
60          }
61      }
62  
63      private static class WebSocketCookieStore implements HttpCookieStore {
64  
65          private final WebClient webClient_;
66  
67          WebSocketCookieStore(final WebClient webClient) {
68              webClient_ = webClient;
69          }
70  
71          @Override
72          public boolean add(final URI uri, final HttpCookie cookie) {
73              return false;
74          }
75  
76          @Override
77          public List<HttpCookie> all() {
78              throw new UnsupportedOperationException();
79          }
80  
81          @Override
82          public List<HttpCookie> match(final URI uri) {
83              final List<HttpCookie> cookies = new ArrayList<>();
84              try {
85                  final String urlString = uri.toString()
86                          .replace("ws://", "http://")
87                          .replace("wss://", "https://");
88                  final URL url = new URL(urlString);
89                  for (final Cookie cookie : webClient_.getCookies(url)) {
90                      final HttpCookie.Builder builder = HttpCookie.build(cookie.getName(), cookie.getValue());
91                      if (cookie.getDomain() != null) {
92                          builder.domain(cookie.getDomain());
93                      }
94                      if (cookie.getPath() != null) {
95                          builder.path(cookie.getPath());
96                      }
97                      if (cookie.getExpires() != null) {
98                          builder.expires(cookie.getExpires().toInstant());
99                      }
100                     builder.secure(cookie.isSecure());
101                     builder.httpOnly(cookie.isHttpOnly());
102                     if (cookie.getSameSite() != null) {
103                         final HttpCookie.SameSite sameSite = HttpCookie.SameSite.from(cookie.getSameSite());
104                         if (sameSite != null) {
105                             builder.sameSite(sameSite);
106                         }
107                     }
108                     cookies.add(builder.build());
109                 }
110             }
111             catch (final Exception e) {
112                 throw new RuntimeException(e);
113             }
114             return cookies;
115         }
116 
117         @Override
118         public boolean remove(final URI uri, final HttpCookie cookie) {
119             throw new UnsupportedOperationException();
120         }
121 
122         @Override
123         public boolean clear() {
124             return false;
125         }
126     }
127 
128     private final Object clientLock_ = new Object();
129     private WebSocketClient client_;
130     private final WebSocketListener listener_;
131 
132     private volatile Session incomingSession_;
133     private Session outgoingSession_;
134 
135     /**
136      * Ctor.
137      * @param webClient the {@link WebClient}
138      * @param listener the {@link WebSocketListener}
139      */
140     public JettyWebSocketAdapter(final WebClient webClient, final WebSocketListener listener) {
141         super();
142         final WebClientOptions options = webClient.getOptions();
143 
144         if (webClient.getOptions().isUseInsecureSSL()) {
145             final HttpClient httpClient = new HttpClient();
146             httpClient.setSslContextFactory(new SslContextFactory.Client(true));
147             client_ = new WebSocketClient(httpClient);
148         }
149         else {
150             client_ = new WebSocketClient();
151         }
152 
153         listener_ = listener;
154 
155         // use the same executor as the rest
156         client_.getHttpClient().setExecutor(webClient.getExecutor());
157 
158         client_.getHttpClient().setHttpCookieStore(new WebSocketCookieStore(webClient));
159 
160         int size = options.getWebSocketMaxBinaryMessageSize();
161         if (size > 0) {
162             client_.setMaxBinaryMessageSize(size);
163         }
164         size = options.getWebSocketMaxTextMessageSize();
165         if (size > 0) {
166             client_.setMaxTextMessageSize(size);
167         }
168     }
169 
170     /**
171      * {@inheritDoc}
172      */
173     @Override
174     public void start() throws Exception {
175         synchronized (clientLock_) {
176             client_.start();
177         }
178     }
179 
180     /**
181      * {@inheritDoc}
182      */
183     @Override
184     public void connect(final URI url) throws Exception {
185         synchronized (clientLock_) {
186             final CompletableFuture<Session> connectFuture = client_.connect(new JettyWebSocketAdapterImpl(), url);
187             client_.getExecutor().execute(() -> {
188                 try {
189                     listener_.onWebSocketConnecting();
190                     incomingSession_ = connectFuture.get();
191                 }
192                 catch (final Exception e) {
193                     if (!(e instanceof ExecutionException)
194                             || !(e.getCause() instanceof AsynchronousCloseException)) {
195                         listener_.onWebSocketConnectError(e);
196                     }
197                 }
198             });
199         }
200     }
201 
202     /**
203      * {@inheritDoc}
204      */
205     @Override
206     public void send(final Object content) throws IOException {
207         if (content instanceof String string) {
208             outgoingSession_.sendText(string, Callback.NOOP);
209         }
210         else if (content instanceof ByteBuffer buffer) {
211             outgoingSession_.sendBinary(buffer, Callback.NOOP);
212         }
213         else {
214             throw new IllegalStateException("Not Yet Implemented: WebSocket.send() was used to send non-string value");
215         }
216     }
217 
218     /**
219      * {@inheritDoc}
220      */
221     @Override
222     public void closeIncomingSession() {
223         if (incomingSession_ != null) {
224             incomingSession_.close();
225         }
226     }
227 
228     /**
229      * {@inheritDoc}
230      */
231     @Override
232     public void closeOutgoingSession() {
233         if (outgoingSession_ != null) {
234             outgoingSession_.close();
235         }
236     }
237 
238     /**
239      * {@inheritDoc}
240      */
241     @Override
242     public void closeClient() throws Exception {
243         synchronized (clientLock_) {
244             if (client_ != null) {
245                 client_.stop();
246                 client_.destroy();
247 
248                 // TODO finally ?
249                 client_ = null;
250             }
251         }
252     }
253 
254     /**
255      * Jetty12 based implementation of the WebSocket listener.
256      * Bridges Jetty12 {@link org.htmlunit.jetty.websocket.api.Session.Listener.AutoDemanding}
257      * callbacks to the HtmlUnit {@link WebSocketListener} interface.
258      */
259     public class JettyWebSocketAdapterImpl implements AutoDemanding {
260 
261         /** Ctor. */
262         JettyWebSocketAdapterImpl() {
263             super();
264         }
265 
266         @Override
267         public void onWebSocketOpen(final Session session) {
268             outgoingSession_ = session;
269             listener_.onWebSocketOpen();
270         }
271 
272         @Override
273         public void onWebSocketClose(final int statusCode, final String reason, final Callback callback) {
274             outgoingSession_ = null;
275             listener_.onWebSocketClose(statusCode, reason);
276             callback.succeed();
277         }
278 
279         @Override
280         public void onWebSocketText(final String message) {
281             listener_.onWebSocketText(message);
282         }
283 
284         @Override
285         public void onWebSocketBinary(final ByteBuffer payload, final Callback callback) {
286             listener_.onWebSocketBinary(payload);
287             callback.succeed();
288         }
289 
290         @Override
291         public void onWebSocketError(final Throwable cause) {
292             outgoingSession_ = null;
293 
294             // cause.printStackTrace();
295             if (cause instanceof ClosedChannelException) {
296                 // todo log
297                 return;
298             }
299 
300             listener_.onWebSocketError(cause);
301         }
302     }
303 }