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.javascript.host;
16  
17  import java.io.IOException;
18  import java.net.MalformedURLException;
19  import java.net.URI;
20  import java.net.URL;
21  import java.nio.ByteBuffer;
22  
23  import org.apache.commons.logging.Log;
24  import org.apache.commons.logging.LogFactory;
25  import org.htmlunit.Page;
26  import org.htmlunit.WebClient;
27  import org.htmlunit.WebWindow;
28  import org.htmlunit.corejs.javascript.Context;
29  import org.htmlunit.corejs.javascript.Function;
30  import org.htmlunit.corejs.javascript.Scriptable;
31  import org.htmlunit.corejs.javascript.ScriptableObject;
32  import org.htmlunit.corejs.javascript.VarScope;
33  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer;
34  import org.htmlunit.html.HtmlPage;
35  import org.htmlunit.javascript.AbstractJavaScriptEngine;
36  import org.htmlunit.javascript.JavaScriptEngine;
37  import org.htmlunit.javascript.configuration.JsxClass;
38  import org.htmlunit.javascript.configuration.JsxConstant;
39  import org.htmlunit.javascript.configuration.JsxConstructor;
40  import org.htmlunit.javascript.configuration.JsxFunction;
41  import org.htmlunit.javascript.configuration.JsxGetter;
42  import org.htmlunit.javascript.configuration.JsxSetter;
43  import org.htmlunit.javascript.host.dom.DOMException;
44  import org.htmlunit.javascript.host.event.CloseEvent;
45  import org.htmlunit.javascript.host.event.Event;
46  import org.htmlunit.javascript.host.event.EventTarget;
47  import org.htmlunit.javascript.host.event.MessageEvent;
48  import org.htmlunit.util.UrlUtils;
49  import org.htmlunit.websocket.WebSocketAdapter;
50  import org.htmlunit.websocket.WebSocketListener;
51  
52  /**
53   * A JavaScript object for {@code WebSocket}.
54   *
55   * @author Ahmed Ashour
56   * @author Ronald Brill
57   * @author Madis Pärn
58   *
59   * @see <a href=
60   *      "https://developer.mozilla.org/en/WebSockets/WebSockets_reference/WebSocket">Mozilla
61   *      documentation</a>
62   */
63  @JsxClass
64  public class WebSocket extends EventTarget implements AutoCloseable {
65  
66      private static final Log LOG = LogFactory.getLog(WebSocket.class);
67  
68      /** The connection has not yet been established. */
69      @JsxConstant
70      public static final int CONNECTING = 0;
71      /** The WebSocket connection is established and communication is possible. */
72      @JsxConstant
73      public static final int OPEN = 1;
74      /** The connection is going through the closing handshake. */
75      @JsxConstant
76      public static final int CLOSING = 2;
77      /** The connection has been closed or could not be opened. */
78      @JsxConstant
79      public static final int CLOSED = 3;
80  
81      private Function closeHandler_;
82      private Function errorHandler_;
83      private Function messageHandler_;
84      private Function openHandler_;
85      private URI url_;
86      private int readyState_ = CONNECTING;
87      private String binaryType_ = "blob";
88  
89      private HtmlPage containingPage_;
90      private WebSocketAdapter webSocketImpl_;
91      private boolean originSet_;
92  
93      /**
94       * Creates a new instance.
95       */
96      public WebSocket() {
97          super();
98      }
99  
100     /**
101      * Creates a new instance.
102      *
103      * @param url    the URL to which to connect
104      * @param scope  the scope
105      * @param window the top level window
106      */
107     private WebSocket(final String url, final VarScope scope, final Window window) {
108         super();
109         try {
110             final WebWindow webWindow = window.getWebWindow();
111             containingPage_ = (HtmlPage) webWindow.getEnclosedPage();
112 
113             setParentScope(scope);
114             setDomNode(containingPage_.getDocumentElement(), false);
115 
116             final WebClient webClient = webWindow.getWebClient();
117             originSet_ = true;
118 
119             final WebSocketListener webSocketListener = new WebSocketListener() {
120 
121                 @Override
122                 public void onWebSocketConnecting() {
123                     setReadyState(CONNECTING);
124                 }
125 
126                 @Override
127                 public void onWebSocketOpen() {
128                     setReadyState(OPEN);
129 
130                     final Event openEvent = new Event(Event.TYPE_OPEN);
131                     openEvent.setParentScope(scope);
132                     openEvent.setPrototype(getPrototype(openEvent.getClass()));
133                     openEvent.setSrcElement(WebSocket.this);
134                     fire(openEvent);
135                     callFunction(openHandler_, new Object[] {openEvent});
136                 }
137 
138                 @Override
139                 public void onWebSocketClose(final int statusCode, final String reason) {
140                     setReadyState(CLOSED);
141 
142                     final CloseEvent closeEvent = new CloseEvent();
143                     closeEvent.setParentScope(scope);
144                     closeEvent.setPrototype(getPrototype(closeEvent.getClass()));
145                     closeEvent.setCode(statusCode);
146                     closeEvent.setReason(reason);
147                     closeEvent.setWasClean(statusCode == 1000);
148                     fire(closeEvent);
149                     callFunction(closeHandler_, new Object[] {closeEvent});
150                 }
151 
152                 @Override
153                 public void onWebSocketText(final String message) {
154                     final MessageEvent msgEvent = new MessageEvent(message);
155                     msgEvent.setParentScope(scope);
156                     msgEvent.setPrototype(getPrototype(msgEvent.getClass()));
157                     if (originSet_) {
158                         try {
159                             URL originUrl = UrlUtils.toUrlUnsafe(getUrl());
160                             originUrl = UrlUtils.getUrlWithoutPathRefQuery(originUrl);
161                             msgEvent.setOrigin(originUrl.toExternalForm());
162                         }
163                         catch (final MalformedURLException e) {
164                             // ignore
165                         }
166                     }
167                     msgEvent.setSrcElement(WebSocket.this);
168                     fire(msgEvent);
169                     callFunction(messageHandler_, new Object[] {msgEvent});
170                 }
171 
172                 @Override
173                 public void onWebSocketBinary(final ByteBuffer payload) {
174                     final NativeArrayBuffer buffer = new NativeArrayBuffer(payload.remaining());
175                     payload.get(buffer.getBuffer());
176 
177                     buffer.setParentScope(getParentScope());
178                     buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(), buffer.getClassName()));
179 
180                     final MessageEvent msgEvent = new MessageEvent(buffer);
181                     msgEvent.setParentScope(scope);
182                     msgEvent.setPrototype(getPrototype(msgEvent.getClass()));
183                     if (originSet_) {
184                         try {
185                             URL originUrl = UrlUtils.toUrlUnsafe(getUrl());
186                             originUrl = UrlUtils.getUrlWithoutPathRefQuery(originUrl);
187                             msgEvent.setOrigin(originUrl.toExternalForm());
188                         }
189                         catch (final MalformedURLException e) {
190                             // ignore
191                         }
192                     }
193                     msgEvent.setSrcElement(WebSocket.this);
194                     fire(msgEvent);
195                     callFunction(messageHandler_, new Object[] {msgEvent});
196                 }
197 
198                 @Override
199                 public void onWebSocketConnectError(final Throwable cause) {
200                     if (LOG.isErrorEnabled()) {
201                         LOG.error("WS connect error for url '" + url + "':", cause);
202                     }
203                     onWebSocketError(cause);
204                 }
205 
206                 @Override
207                 public void onWebSocketError(final Throwable cause) {
208                     if (CLOSED == getReadyState()) {
209                         return;
210                     }
211 
212                     setReadyState(CLOSED);
213 
214                     final Event errorEvent = new Event(Event.TYPE_ERROR);
215                     errorEvent.setParentScope(scope);
216                     errorEvent.setPrototype(getPrototype(errorEvent.getClass()));
217                     errorEvent.setSrcElement(WebSocket.this);
218                     fire(errorEvent);
219                     callFunction(errorHandler_, new Object[] {errorEvent});
220 
221                     final CloseEvent closeEvent = new CloseEvent();
222                     closeEvent.setParentScope(scope);
223                     closeEvent.setPrototype(getPrototype(closeEvent.getClass()));
224                     closeEvent.setCode(1006);
225                     closeEvent.setReason(cause.getMessage());
226                     closeEvent.setWasClean(false);
227                     fire(closeEvent);
228                     callFunction(closeHandler_, new Object[] {closeEvent});
229                 }
230             };
231 
232             webSocketImpl_ = webClient.buildWebSocketAdapter(webSocketListener);
233 
234             webSocketImpl_.start();
235             containingPage_.addAutoCloseable(this);
236             url_ = new URI(url);
237 
238             webSocketImpl_.connect(url_);
239         }
240         catch (final Exception e) {
241             if (LOG.isErrorEnabled()) {
242                 LOG.error("WebSocket Error: 'url' parameter '" + url + "' is invalid.", e);
243             }
244             throw JavaScriptEngine.reportRuntimeError("WebSocket Error: 'url' parameter '" + url + "' is invalid.");
245         }
246     }
247 
248     /**
249      * JavaScript constructor.
250      *
251      * @param cx        the current context
252      * @param scope     the scope
253      * @param args      the arguments to the WebSocket constructor
254      * @param ctorObj   the function object
255      * @param inNewExpr Is new or not
256      * @return the java object to allow JavaScript to access
257      */
258     @JsxConstructor
259     public static Scriptable jsConstructor(final Context cx, final VarScope scope, final Object[] args,
260             final Function ctorObj, final boolean inNewExpr) {
261         if (args.length < 1 || args.length > 2) {
262             throw JavaScriptEngine
263                     .typeError("WebSocket Error: constructor must have one or two String parameters.");
264         }
265 
266         final Window win = getWindow(ctorObj);
267         String urlString = JavaScriptEngine.toString(args[0]);
268         try {
269             final Page page = win.getWebWindow().getEnclosedPage();
270             if (page instanceof HtmlPage htmlPage) {
271                 URL url = htmlPage.getFullyQualifiedUrl(urlString);
272 
273                 if (url.getRef() != null) {
274                     throw JavaScriptEngine.asJavaScriptException(
275                             win,
276                             "WebSocket Error: 'url' parameter '" + urlString + "' contains a fragment identifier.",
277                             DOMException.SYNTAX_ERR);
278                 }
279 
280                 // Per spec: only ws/wss are valid; convert http/https (relative resolution), reject everything else
281                 final String scheme = url.getProtocol();
282                 if ("http".equals(scheme)) {
283                     url = UrlUtils.getUrlWithNewProtocol(url, "ws");
284                 }
285                 else if ("https".equals(scheme)) {
286                     url = UrlUtils.getUrlWithNewProtocol(url, "wss");
287                 }
288                 else if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
289                     throw JavaScriptEngine.asJavaScriptException(
290                             win,
291                             "WebSocket Error: 'url' parameter '" + urlString + "' is not a valid url.",
292                             DOMException.SYNTAX_ERR);
293                 }
294 
295                 urlString = url.toExternalForm();
296             }
297         }
298         catch (final MalformedURLException e) {
299             throw JavaScriptEngine.asJavaScriptException(
300                     win,
301                     "WebSocket Error: 'url' parameter '" + urlString + "' is not a valid url.",
302                     DOMException.SYNTAX_ERR);
303         }
304         return new WebSocket(urlString, getTopLevelScope(scope), win);
305     }
306 
307     /**
308      * Returns the event handler that fires on close.
309      *
310      * @return the event handler that fires on close
311      */
312     @JsxGetter
313     public Function getOnclose() {
314         return closeHandler_;
315     }
316 
317     /**
318      * Sets the event handler that fires on close.
319      *
320      * @param closeHandler the event handler that fires on close
321      */
322     @JsxSetter
323     public void setOnclose(final Function closeHandler) {
324         closeHandler_ = closeHandler;
325     }
326 
327     /**
328      * Returns the event handler that fires on error.
329      *
330      * @return the event handler that fires on error
331      */
332     @JsxGetter
333     public Function getOnerror() {
334         return errorHandler_;
335     }
336 
337     /**
338      * Sets the event handler that fires on error.
339      *
340      * @param errorHandler the event handler that fires on error
341      */
342     @JsxSetter
343     public void setOnerror(final Function errorHandler) {
344         errorHandler_ = errorHandler;
345     }
346 
347     /**
348      * Returns the event handler that fires on message.
349      *
350      * @return the event handler that fires on message
351      */
352     @JsxGetter
353     public Function getOnmessage() {
354         return messageHandler_;
355     }
356 
357     /**
358      * Sets the event handler that fires on message.
359      *
360      * @param messageHandler the event handler that fires on message
361      */
362     @JsxSetter
363     public void setOnmessage(final Function messageHandler) {
364         messageHandler_ = messageHandler;
365     }
366 
367     /**
368      * Returns the event handler that fires on open.
369      *
370      * @return the event handler that fires on open
371      */
372     @JsxGetter
373     public Function getOnopen() {
374         return openHandler_;
375     }
376 
377     /**
378      * Sets the event handler that fires on open.
379      *
380      * @param openHandler the event handler that fires on open
381      */
382     @JsxSetter
383     public void setOnopen(final Function openHandler) {
384         openHandler_ = openHandler;
385     }
386 
387     /**
388      * Returns The current state of the connection. The possible values are:
389      * {@link #CONNECTING}, {@link #OPEN}, {@link #CLOSING} or {@link #CLOSED}.
390      *
391      * @return the current state of the connection
392      */
393     @JsxGetter
394     public int getReadyState() {
395         return readyState_;
396     }
397 
398     void setReadyState(final int readyState) {
399         readyState_ = readyState;
400     }
401 
402     /**
403      * @return the url
404      */
405     @JsxGetter
406     public String getUrl() {
407         if (url_ == null) {
408             throw JavaScriptEngine.typeError("invalid call");
409         }
410         return url_.toString();
411     }
412 
413     /**
414      * @return the sub protocol used
415      */
416     @JsxGetter
417     public String getProtocol() {
418         return "";
419     }
420 
421     /**
422      * @return the sub protocol used
423      */
424     @JsxGetter
425     public long getBufferedAmount() {
426         return 0L;
427     }
428 
429     /**
430      * @return the used binary type
431      */
432     @JsxGetter
433     public String getBinaryType() {
434         return binaryType_;
435     }
436 
437     /**
438      * Sets the used binary type.
439      *
440      * @param type the type
441      */
442     @JsxSetter
443     public void setBinaryType(final String type) {
444         if ("arraybuffer".equals(type) || "blob".equals(type)) {
445             binaryType_ = type;
446         }
447     }
448 
449     /**
450      * {@inheritDoc}
451      */
452     @Override
453     public void close() throws IOException {
454         close(null, null);
455     }
456 
457     /**
458      * Closes the WebSocket connection or connection attempt, if any. If the
459      * connection is already {@link #CLOSED}, this method does nothing.
460      *
461      * @param code   A numeric value indicating the status code explaining why the
462      *               connection is being closed
463      * @param reason A human-readable string explaining why the connection is
464      *               closing
465      */
466     @JsxFunction
467     public void close(final Object code, final Object reason) {
468         if (webSocketImpl_ == null) {
469             return;
470         }
471 
472         if (readyState_ != CLOSED) {
473             try {
474                 webSocketImpl_.closeIncomingSession();
475             }
476             catch (final Throwable e) {
477                 LOG.error("WS close error - incomingSession_.close() failed", e);
478             }
479 
480             try {
481                 webSocketImpl_.closeOutgoingSession();
482             }
483             catch (final Throwable e) {
484                 LOG.error("WS close error - outgoingSession_.close() failed", e);
485             }
486         }
487 
488         try {
489             webSocketImpl_.closeClient();
490         }
491         catch (final Exception e) {
492             throw new RuntimeException(e);
493         }
494     }
495 
496     /**
497      * Transmits data to the server over the WebSocket connection.
498      *
499      * @param content the body of the message being sent with the request
500      */
501     @JsxFunction
502     public void send(final Object content) {
503         try {
504             if (content instanceof NativeArrayBuffer buffer1) {
505                 final byte[] bytes = buffer1.getBuffer();
506                 final ByteBuffer buffer = ByteBuffer.wrap(bytes);
507                 webSocketImpl_.send(buffer);
508                 return;
509             }
510             webSocketImpl_.send(content);
511         }
512         catch (final IOException e) {
513             LOG.error("WS send error", e);
514         }
515     }
516 
517     void fire(final Event evt) {
518         evt.setTarget(this);
519         evt.setParentScope(getParentScope());
520         evt.setPrototype(getPrototype(evt.getClass()));
521 
522         final AbstractJavaScriptEngine<?> engine = containingPage_.getWebClient().getJavaScriptEngine();
523         if (engine != null) {
524             engine.getContextFactory().call(cx -> {
525                 executeEventLocally(evt);
526                 return null;
527             });
528         }
529     }
530 
531     void callFunction(final Function function, final Object[] args) {
532         if (function == null) {
533             return;
534         }
535         final VarScope scope = function.getParentScope();
536         final JavaScriptEngine engine = (JavaScriptEngine) containingPage_.getWebClient().getJavaScriptEngine();
537         if (engine != null) {
538             engine.callFunction(containingPage_, function, scope, this, args);
539         }
540     }
541 }