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.api.UpgradeResponse;
39  import org.htmlunit.jetty.websocket.client.ClientUpgradeRequest;
40  import org.htmlunit.jetty.websocket.client.WebSocketClient;
41  
42  /**
43   * Jetty12 based impl of the WebSocketAdapter.
44   * To avoid conflicts with other jetty versions used by projects, we use
45   * our own shaded version of jetty12 (https://github.com/HtmlUnit/htmlunit-websocket-client).
46   *
47   * @author Ronald Brill
48   */
49  public final class JettyWebSocketAdapter implements WebSocketAdapter {
50  
51      /**
52       * Our {@link WebSocketAdapterFactory}.
53       */
54      public static final class JettyWebSocketAdapterFactory implements WebSocketAdapterFactory {
55          /**
56           * {@inheritDoc}
57           */
58          @Override
59          public WebSocketAdapter buildWebSocketAdapter(final WebClient webClient,
60                  final WebSocketListener webSocketListener) {
61              return new JettyWebSocketAdapter(webClient, webSocketListener);
62          }
63      }
64  
65      private static class WebSocketCookieStore implements HttpCookieStore {
66  
67          private final WebClient webClient_;
68  
69          WebSocketCookieStore(final WebClient webClient) {
70              webClient_ = webClient;
71          }
72  
73          @Override
74          public boolean add(final URI uri, final HttpCookie cookie) {
75              return false;
76          }
77  
78          @Override
79          public List<HttpCookie> all() {
80              throw new UnsupportedOperationException();
81          }
82  
83          @Override
84          public List<HttpCookie> match(final URI uri) {
85              final List<HttpCookie> cookies = new ArrayList<>();
86              try {
87                  final String urlString = uri.toString()
88                          .replace("ws://", "http://")
89                          .replace("wss://", "https://");
90                  final URL url = new URL(urlString);
91                  for (final Cookie cookie : webClient_.getCookies(url)) {
92                      final HttpCookie.Builder builder = HttpCookie.build(cookie.getName(), cookie.getValue());
93                      if (cookie.getDomain() != null) {
94                          builder.domain(cookie.getDomain());
95                      }
96                      if (cookie.getPath() != null) {
97                          builder.path(cookie.getPath());
98                      }
99                      if (cookie.getExpires() != null) {
100                         builder.expires(cookie.getExpires().toInstant());
101                     }
102                     builder.secure(cookie.isSecure());
103                     builder.httpOnly(cookie.isHttpOnly());
104                     if (cookie.getSameSite() != null) {
105                         final HttpCookie.SameSite sameSite = HttpCookie.SameSite.from(cookie.getSameSite());
106                         if (sameSite != null) {
107                             builder.sameSite(sameSite);
108                         }
109                     }
110                     cookies.add(builder.build());
111                 }
112             }
113             catch (final Exception e) {
114                 throw new RuntimeException(e);
115             }
116             return cookies;
117         }
118 
119         @Override
120         public boolean remove(final URI uri, final HttpCookie cookie) {
121             throw new UnsupportedOperationException();
122         }
123 
124         @Override
125         public boolean clear() {
126             return false;
127         }
128     }
129 
130     private final Object clientLock_ = new Object();
131     private WebSocketClient client_;
132     private final WebSocketListener listener_;
133 
134     private volatile Session incomingSession_;
135     private volatile Session outgoingSession_;
136 
137     /**
138      * Ctor.
139      * @param webClient the {@link WebClient}
140      * @param listener the {@link WebSocketListener}
141      */
142     public JettyWebSocketAdapter(final WebClient webClient, final WebSocketListener listener) {
143         super();
144         final WebClientOptions options = webClient.getOptions();
145 
146         if (webClient.getOptions().isUseInsecureSSL()) {
147             final HttpClient httpClient = new HttpClient();
148             httpClient.setSslContextFactory(new SslContextFactory.Client(true));
149             client_ = new WebSocketClient(httpClient);
150         }
151         else {
152             client_ = new WebSocketClient();
153         }
154 
155         listener_ = listener;
156 
157         // use the same executor as the rest
158         client_.getHttpClient().setExecutor(webClient.getExecutor());
159 
160         client_.getHttpClient().setHttpCookieStore(new WebSocketCookieStore(webClient));
161 
162         int size = options.getWebSocketMaxBinaryMessageSize();
163         if (size > 0) {
164             client_.setMaxBinaryMessageSize(size);
165         }
166         size = options.getWebSocketMaxTextMessageSize();
167         if (size > 0) {
168             client_.setMaxTextMessageSize(size);
169         }
170     }
171 
172     /**
173      * {@inheritDoc}
174      */
175     @Override
176     public void start() throws Exception {
177         synchronized (clientLock_) {
178             client_.start();
179         }
180     }
181 
182     /**
183      * {@inheritDoc}
184      */
185     @Override
186     public void connect(final URI url) throws Exception {
187         synchronized (clientLock_) {
188             listenForConnect(client_.connect(new JettyWebSocketAdapterImpl(), url));
189         }
190     }
191 
192     /**
193      * {@inheritDoc}
194      */
195     @Override
196     public void connect(final URI url, final List<String> protocols) throws Exception {
197         if (protocols.isEmpty()) {
198             connect(url);
199             return;
200         }
201 
202         synchronized (clientLock_) {
203             final ClientUpgradeRequest request = new ClientUpgradeRequest(url);
204             request.setSubProtocols(protocols);
205             listenForConnect(client_.connect(new JettyWebSocketAdapterImpl(), request));
206         }
207     }
208 
209     private void listenForConnect(final CompletableFuture<Session> connectFuture) {
210         client_.getExecutor().execute(() -> {
211             try {
212                 listener_.onWebSocketConnecting();
213                 incomingSession_ = connectFuture.get();
214             }
215             catch (final Exception e) {
216                 if (!(e instanceof ExecutionException)
217                         || !(e.getCause() instanceof AsynchronousCloseException)) {
218                     listener_.onWebSocketConnectError(e);
219                 }
220             }
221         });
222     }
223 
224     /**
225      * {@inheritDoc}
226      */
227     @Override
228     public String getSelectedProtocol() {
229         final Session session = outgoingSession_;
230         if (session == null) {
231             return "";
232         }
233 
234         final UpgradeResponse response = session.getUpgradeResponse();
235         final String protocol = response == null ? null : response.getAcceptedSubProtocol();
236         return protocol == null ? "" : protocol;
237     }
238 
239     /**
240      * {@inheritDoc}
241      */
242     @Override
243     public void send(final Object content) throws IOException {
244         final Session session = outgoingSession_;
245         if (session == null) {
246             throw new IOException("WebSocket is not connected");
247         }
248 
249         if (content instanceof String string) {
250             session.sendText(string, Callback.NOOP);
251         }
252         else if (content instanceof ByteBuffer buffer) {
253             session.sendBinary(buffer, Callback.NOOP);
254         }
255         else {
256             throw new IllegalStateException("Not Yet Implemented: WebSocket.send() was used to send non-string value");
257         }
258     }
259 
260     /**
261      * {@inheritDoc}
262      */
263     @Override
264     public void closeIncomingSession() {
265         final Session session = incomingSession_;
266         if (session != null) {
267             session.close();
268         }
269     }
270 
271     /**
272      * {@inheritDoc}
273      */
274     @Override
275     public void closeOutgoingSession() {
276         final Session session = outgoingSession_;
277         if (session != null) {
278             session.close();
279         }
280     }
281 
282     /**
283      * {@inheritDoc}
284      */
285     @Override
286     public void closeOutgoingSession(final int statusCode, final String reason) {
287         final Session session = outgoingSession_;
288         if (session != null) {
289             session.close(statusCode, reason, Callback.NOOP);
290         }
291     }
292 
293     /**
294      * {@inheritDoc}
295      */
296     @Override
297     public void closeClient() throws Exception {
298         final WebSocketClient client;
299         synchronized (clientLock_) {
300             client = client_;
301             client_ = null;
302         }
303 
304         if (client != null) {
305             client.stop();
306             client.destroy();
307         }
308     }
309 
310     /**
311      * Jetty12 based implementation of the WebSocket listener.
312      * Bridges Jetty12 {@link org.htmlunit.jetty.websocket.api.Session.Listener.AutoDemanding}
313      * callbacks to the HtmlUnit {@link WebSocketListener} interface.
314      */
315     public class JettyWebSocketAdapterImpl implements AutoDemanding {
316 
317         /** Ctor. */
318         JettyWebSocketAdapterImpl() {
319             super();
320         }
321 
322         @Override
323         public void onWebSocketOpen(final Session session) {
324             outgoingSession_ = session;
325             listener_.onWebSocketOpen();
326         }
327 
328         @Override
329         public void onWebSocketClose(final int statusCode, final String reason, final Callback callback) {
330             outgoingSession_ = null;
331 
332             // complete the closing handshake first: the listener may release the client
333             callback.succeed();
334             listener_.onWebSocketClose(statusCode, reason);
335         }
336 
337         @Override
338         public void onWebSocketText(final String message) {
339             listener_.onWebSocketText(message);
340         }
341 
342         @Override
343         public void onWebSocketBinary(final ByteBuffer payload, final Callback callback) {
344             try {
345                 listener_.onWebSocketBinary(payload);
346                 callback.succeed();
347             }
348             catch (final Throwable ex) {
349                 callback.fail(ex);
350             }
351         }
352 
353         @Override
354         public void onWebSocketError(final Throwable cause) {
355             outgoingSession_ = null;
356 
357             // cause.printStackTrace();
358             if (cause instanceof ClosedChannelException) {
359                 // todo log
360                 return;
361             }
362 
363             listener_.onWebSocketError(cause);
364         }
365     }
366 }