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