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