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.URISyntaxException;
21  import java.net.URL;
22  import java.nio.ByteBuffer;
23  import java.util.ArrayList;
24  import java.util.Collections;
25  import java.util.HashSet;
26  import java.util.List;
27  import java.util.concurrent.RejectedExecutionException;
28  import java.util.concurrent.atomic.AtomicInteger;
29  import java.util.concurrent.atomic.AtomicLong;
30  
31  import org.apache.commons.logging.Log;
32  import org.apache.commons.logging.LogFactory;
33  import org.htmlunit.WebClient;
34  import org.htmlunit.corejs.javascript.Context;
35  import org.htmlunit.corejs.javascript.Function;
36  import org.htmlunit.corejs.javascript.Scriptable;
37  import org.htmlunit.corejs.javascript.ScriptableObject;
38  import org.htmlunit.corejs.javascript.VarScope;
39  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer;
40  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBufferView;
41  import org.htmlunit.html.HtmlPage;
42  import org.htmlunit.javascript.AbstractJavaScriptEngine;
43  import org.htmlunit.javascript.JavaScriptEngine;
44  import org.htmlunit.javascript.background.BasicJavaScriptJob;
45  import org.htmlunit.javascript.background.JavaScriptJob;
46  import org.htmlunit.javascript.configuration.JsxClass;
47  import org.htmlunit.javascript.configuration.JsxConstant;
48  import org.htmlunit.javascript.configuration.JsxConstructor;
49  import org.htmlunit.javascript.configuration.JsxFunction;
50  import org.htmlunit.javascript.configuration.JsxGetter;
51  import org.htmlunit.javascript.configuration.JsxSetter;
52  import org.htmlunit.javascript.host.dom.DOMException;
53  import org.htmlunit.javascript.host.event.CloseEvent;
54  import org.htmlunit.javascript.host.event.Event;
55  import org.htmlunit.javascript.host.event.EventTarget;
56  import org.htmlunit.javascript.host.event.MessageEvent;
57  import org.htmlunit.javascript.host.file.Blob;
58  import org.htmlunit.util.UrlUtils;
59  import org.htmlunit.websocket.WebSocketAdapter;
60  import org.htmlunit.websocket.WebSocketListener;
61  
62  /**
63   * JavaScript host object for {@code WebSocket}.
64   *
65   * <p>Ready state handling: readyState_ is read and written by the JS thread (close(), send(),
66   * getReadyState()), by the threads of the websocket adapter and by queued tasks. Every change goes through
67   * one of the three methods switchToOpen(), switchToClosing() and switchToClosed(). Each is a single atomic
68   * operation and tells the caller which state was left, so only the caller that really performed a
69   * transition fires the matching event. That gives at most one open event and exactly one close event
70   * whatever the interleaving.</p>
71   *
72   * <pre>{@code
73   *   CONNECTING      -> OPEN      switchToOpen()      handshake finished
74   *   OPEN            -> CLOSING   switchToClosing()   close() called
75   *   CONNECTING      -> ABORTED   switchToClosing()   close() called, reported as CLOSING
76   *   any but CLOSED  -> CLOSED    switchToClosed()    connection ended, see connectionEnded()
77   * }</pre>
78   *
79   * <p>The state never moves backwards and CLOSED is final.</p>
80   *
81   * @author Ahmed Ashour
82   * @author Ronald Brill
83   * @author Madis Pärn
84   *
85   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket">MDN Documentation</a>
86   */
87  @JsxClass
88  public class WebSocket extends EventTarget implements AutoCloseable {
89  
90      private static final Log LOG = LogFactory.getLog(WebSocket.class);
91  
92      /** The connection has not yet been established. */
93      @JsxConstant
94      public static final int CONNECTING = 0;
95      /** The WebSocket connection is established and communication is possible. */
96      @JsxConstant
97      public static final int OPEN = 1;
98      /** The connection is going through the closing handshake. */
99      @JsxConstant
100     public static final int CLOSING = 2;
101     /** The connection has been closed or could not be opened. */
102     @JsxConstant
103     public static final int CLOSED = 3;
104 
105     /**
106      * Internal state, never visible to scripts (reported as {@link #CLOSING}): close() was called while
107      * the connection was still CONNECTING. Such a connection fails: error event, then close event 1006.
108      */
109     private static final int ABORTED = 4;
110 
111     /** The close frame has 125 bytes of payload, two of them are the status code. */
112     private static final int MAX_CLOSE_REASON_BYTES = 123;
113 
114     /** The status code for "no status code": the close frame is sent without any. */
115     private static final int NO_STATUS_CODE = 1005;
116 
117     /** The status code of a normal closure. */
118     private static final int CLOSE_NORMAL = 1000;
119 
120     /** The status code sent when the server violated the protocol, e.g. selected a sub-protocol nobody offered. */
121     private static final int CLOSE_PROTOCOL_ERROR = 1002;
122 
123     /** The status code reported locally for a connection that was lost or failed; it is never sent. */
124     private static final int CLOSE_ABNORMAL = 1006;
125 
126     /** The status code reported locally for a failed TLS handshake; it is never sent. */
127     private static final int CLOSE_TLS_FAILURE = 1015;
128 
129     /** Returned by switchToClosing() if the connection already is CLOSING or CLOSED. */
130     private static final int NOT_CLOSABLE = -1;
131 
132     /** Tells closeSessions() to close with the default status code of the adapter. */
133     private static final int DEFAULT_STATUS_CODE = -1;
134 
135     private final URI url_;
136     private final String origin_;
137 
138     private final AtomicInteger readyState_ = new AtomicInteger(CONNECTING);
139     // bytes handed to send() after the connection was closed; see getBufferedAmount()
140     private final AtomicLong bufferedAmount_ = new AtomicLong();
141     private volatile String binaryType_ = "blob";
142 
143     private HtmlPage containingPage_;
144     private WebSocketAdapter webSocketImpl_;
145 
146     // the sub-protocols offered in the handshake, and the one the server selected
147     private final List<String> requestedProtocols_;
148     private volatile String protocol_ = "";
149 
150     /**
151      * Creates a new instance.
152      */
153     public WebSocket() {
154         super();
155         requestedProtocols_ = null;
156         url_ = null;
157         origin_ = null;
158     }
159 
160     /**
161      * Creates a new instance connected to the given URL.
162      *
163      * @param url the URL to connect to
164      * @param protocols the sub-protocols to offer in the handshake
165      * @param scope the scope
166      * @param window the top-level window
167      * @param page the page this WebSocket belongs to
168      */
169     private WebSocket(final URI url, final List<String> protocols, final VarScope scope,
170             final Window window, final HtmlPage page) {
171         super();
172         url_ = url;
173         origin_ = originOf(url);
174 
175         requestedProtocols_ = protocols;
176         containingPage_ = page;
177 
178         setParentScope(scope);
179         setDomNode(page.getDocumentElement(), false);
180 
181         final WebClient webClient = window.getWebWindow().getWebClient();
182         webSocketImpl_ = webClient.buildWebSocketAdapter(new AdapterListener(scope));
183 
184         try {
185             webSocketImpl_.start();
186             webSocketImpl_.connect(url_, requestedProtocols_);
187         }
188         catch (final Exception e) {
189             if (LOG.isErrorEnabled()) {
190                 LOG.error("WebSocket Error: connecting to '" + url + "' failed.", e);
191             }
192 
193             // nothing was handed out yet, so nobody else will release the client
194             releaseClient();
195 
196             throw JavaScriptEngine.reportRuntimeError("WebSocket Error: connecting to '" + url + "' failed.");
197         }
198 
199         page.addAutoCloseable(this);
200     }
201 
202     /**
203      * Creates an instance of this object.
204      *
205      * @param cx the current context
206      * @param scope the scope
207      * @param args the constructor arguments
208      * @param ctorObj the function object
209      * @param inNewExpr whether invoked via {@code new}
210      * @return the new {@code WebSocket} instance
211      */
212     @JsxConstructor
213     public static Scriptable jsConstructor(final Context cx, final VarScope scope, final Object[] args,
214             final Function ctorObj, final boolean inNewExpr) {
215         if (!inNewExpr) {
216             throw JavaScriptEngine
217                     .typeError("Failed to construct 'WebSocket': Please use the 'new' operator, "
218                             + "this DOM object constructor cannot be called as a function.");
219         }
220 
221         // the url is required, the protocols are optional, additional arguments are ignored
222         if (args.length < 1) {
223             throw JavaScriptEngine
224                     .typeError("WebSocket Error: constructor needs at least the url as parameter.");
225         }
226 
227         final Window win = getWindow(ctorObj);
228         if (!(win.getWebWindow().getEnclosedPage() instanceof HtmlPage htmlPage)) {
229             throw JavaScriptEngine.typeError("WebSocket Error: WebSocket is only available in HTML pages.");
230         }
231 
232         String urlString = JavaScriptEngine.toString(args[0]);
233         try {
234             URL url = htmlPage.getFullyQualifiedUrl(urlString);
235 
236             if (url.getRef() != null) {
237                 throw JavaScriptEngine.asJavaScriptException(win,
238                         "WebSocket Error: 'url' parameter '" + urlString + "' contains a fragment identifier.",
239                         DOMException.SYNTAX_ERR);
240             }
241 
242             // Per spec: only ws/wss are valid; convert http/https (relative resolution), reject everything else
243             final String scheme = url.getProtocol();
244             if ("http".equals(scheme)) {
245                 url = UrlUtils.getUrlWithNewProtocol(url, "ws");
246             }
247             else if ("https".equals(scheme)) {
248                 url = UrlUtils.getUrlWithNewProtocol(url, "wss");
249             }
250             else if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
251                 throw JavaScriptEngine.asJavaScriptException(win,
252                         "WebSocket Error: 'url' parameter '" + urlString + "' is not a valid url.",
253                         DOMException.SYNTAX_ERR);
254             }
255 
256             urlString = url.toExternalForm();
257         }
258         catch (final MalformedURLException e) {
259             throw JavaScriptEngine.asJavaScriptException(
260                     win,
261                     "WebSocket Error: 'url' parameter '" + urlString + "' is not a valid url.",
262                     DOMException.SYNTAX_ERR);
263         }
264 
265         final URI uri;
266         try {
267             uri = new URI(urlString);
268         }
269         catch (final URISyntaxException e) {
270             throw JavaScriptEngine.asJavaScriptException(win,
271                     "WebSocket Error: 'url' parameter '" + urlString + "' is not a valid url.",
272                     DOMException.SYNTAX_ERR);
273         }
274 
275         final List<String> protocols = toProtocols(cx, scope, args.length > 1 ? args[1] : JavaScriptEngine.UNDEFINED);
276         if (!areValidProtocols(protocols)) {
277             throw JavaScriptEngine.asJavaScriptException(
278                     win,
279                     "WebSocket Error: the protocols must be unique and may only contain token characters.",
280                     DOMException.SYNTAX_ERR);
281         }
282 
283         return new WebSocket(uri, List.copyOf(protocols), getTopLevelScope(scope), win, htmlPage);
284     }
285 
286     /**
287      * Converts the protocols argument, which is a {@code (DOMString or sequence<DOMString>)}: undefined (or no
288      * argument) is the empty list, an array is a sequence, anything else - null and objects that are no array
289      * included - is converted to a single string.
290      *
291      * @param cx the current context
292      * @param scope the scope
293      * @param protocols the argument
294      * @return the protocols
295      */
296     private static List<String> toProtocols(final Context cx, final VarScope scope, final Object protocols) {
297         if (JavaScriptEngine.isUndefined(protocols)) {
298             return Collections.emptyList();
299         }
300 
301         if (protocols instanceof Scriptable protoScriptable) {
302             final List<String> result = new ArrayList<>();
303             if (JavaScriptEngine.iterate(cx, scope, protoScriptable,
304                     elem -> {
305                         if (elem == Scriptable.NOT_FOUND) {
306                             // a hole in the array
307                             result.add(JavaScriptEngine.toString(JavaScriptEngine.UNDEFINED));
308                         }
309                         else {
310                             result.add(JavaScriptEngine.toString(elem));
311                         }
312                     })) {
313                 return result;
314             }
315         }
316 
317         return Collections.singletonList(JavaScriptEngine.toString(protocols));
318     }
319 
320     /**
321      * Checks the protocols: every one has to be a token, and none may be there more than once
322      * (compared case sensitive).
323      *
324      * @param protocols the protocols
325      * @return {@code true} if the protocols are valid
326      */
327     private static boolean areValidProtocols(final List<String> protocols) {
328         final HashSet<String> seen = new HashSet<>();
329         for (final String protocol : protocols) {
330             if (!isValidProtocol(protocol) || !seen.add(protocol)) {
331                 return false;
332             }
333         }
334         return true;
335     }
336 
337     /**
338      * A protocol has to match the token production of RFC 7230: at least one character, only letters, digits
339      * and {@code !#$%&'*+-.^_`|~}. That excludes empty strings, blanks, separators like the comma and all non
340      * ASCII characters.
341      *
342      * @param protocol the protocol
343      * @return {@code true} if the protocol is a token
344      */
345     private static boolean isValidProtocol(final String protocol) {
346         if (protocol.isEmpty()) {
347             return false;
348         }
349         for (int i = 0; i < protocol.length(); i++) {
350             final char c = protocol.charAt(i);
351             final boolean token = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
352                     || "!#$%&'*+-.^_`|~".indexOf(c) >= 0;
353             if (!token) {
354                 return false;
355             }
356         }
357         return true;
358     }
359 
360     /**
361      * Returns the event handler that fires on close.
362      *
363      * @return the event handler that fires on close
364      */
365     @JsxGetter
366     public Function getOnclose() {
367         return getEventHandler(Event.TYPE_CLOSE);
368     }
369 
370     /**
371      * Sets the event handler that fires on close.
372      *
373      * @param closeHandler the event handler that fires on close
374      */
375     @JsxSetter
376     public void setOnclose(final Function closeHandler) {
377         setEventHandler(Event.TYPE_CLOSE, closeHandler);
378     }
379 
380     /**
381      * Returns the event handler that fires on error.
382      *
383      * @return the event handler that fires on error
384      */
385     @JsxGetter
386     public Function getOnerror() {
387         return getEventHandler(Event.TYPE_ERROR);
388     }
389 
390     /**
391      * Sets the event handler that fires on error.
392      *
393      * @param errorHandler the event handler that fires on error
394      */
395     @JsxSetter
396     public void setOnerror(final Function errorHandler) {
397         setEventHandler(Event.TYPE_ERROR, errorHandler);
398     }
399 
400     /**
401      * Returns the event handler that fires on message.
402      *
403      * @return the event handler that fires on message
404      */
405     @JsxGetter
406     public Function getOnmessage() {
407         return getEventHandler(Event.TYPE_MESSAGE);
408     }
409 
410     /**
411      * Sets the event handler that fires on message.
412      *
413      * @param messageHandler the event handler that fires on message
414      */
415     @JsxSetter
416     public void setOnmessage(final Function messageHandler) {
417         setEventHandler(Event.TYPE_MESSAGE, messageHandler);
418     }
419 
420     /**
421      * Returns the event handler that fires on open.
422      *
423      * @return the event handler that fires on open
424      */
425     @JsxGetter
426     public Function getOnopen() {
427         return getEventHandler(Event.TYPE_OPEN);
428     }
429 
430     /**
431      * Sets the event handler that fires on open.
432      *
433      * @param openHandler the event handler that fires on open
434      */
435     @JsxSetter
436     public void setOnopen(final Function openHandler) {
437         setEventHandler(Event.TYPE_OPEN, openHandler);
438     }
439 
440     /**
441      * Returns the origin of the given url, which is the url without path, query and fragment.
442      *
443      * @param url the url of the connection
444      * @return the origin, or {@code null} if the url cannot be converted
445      */
446     private static String originOf(final URI url) {
447         try {
448             final URL originUrl = UrlUtils.getUrlWithoutPathRefQuery(UrlUtils.toUrlUnsafe(url.toString()));
449             return originUrl.toExternalForm();
450         }
451         catch (final MalformedURLException e) {
452             return null;
453         }
454     }
455 
456     /**
457      * Switches from CONNECTING to OPEN.
458      *
459      * @return {@code true} if this call made the transition
460      */
461     private boolean switchToOpen() {
462         return readyState_.compareAndSet(CONNECTING, OPEN);
463     }
464 
465     /**
466      * Return the state that was left, or {@link #NOT_CLOSABLE} if the connection already is CLOSING or CLOSED.
467      *
468      * @return the state that was left, or {@link #NOT_CLOSABLE} if the connection already is CLOSING or CLOSED
469      */
470     private int switchToClosing() {
471         int current = readyState_.get();
472         while (current == CONNECTING || current == OPEN) {
473             final int target = current == CONNECTING ? ABORTED : CLOSING;
474             if (readyState_.compareAndSet(current, target)) {
475                 return current;
476             }
477             current = readyState_.get();
478         }
479         return NOT_CLOSABLE;
480     }
481 
482     /**
483      * Return the state that was left, {@link #CLOSED} if the connection was closed before.
484      *
485      * @return the state that was left, {@link #CLOSED} if the connection was closed before
486      */
487     private int switchToClosed() {
488         return readyState_.getAndSet(CLOSED);
489     }
490 
491     /**
492      * A connection was closed cleanly if the closing handshake was completed, whatever status code it carried.
493      * The codes 1006 and 1015 are never sent, they are reported locally for a connection that was lost or failed.
494      *
495      * @param failed whether the connection failed
496      * @param statusCode the status code of the close event
497      * @return the value of {@code wasClean}
498      */
499     private static boolean wasClean(final boolean failed, final int statusCode) {
500         return !failed && statusCode != CLOSE_ABNORMAL && statusCode != CLOSE_TLS_FAILURE;
501     }
502 
503     /**
504      * Starts the closing handshake with the default status code of the adapter.
505      *
506      * @return {@code false} if this failed, so that no answer of the server has to be waited for
507      */
508     private boolean closeSessions() {
509         return closeSessions(DEFAULT_STATUS_CODE, null);
510     }
511 
512     /**
513      * Starts the closing handshake: the sessions are asked to close, which sends the close frame. The outgoing
514      * session comes first because it is the one that takes the status code and the reason; the incoming session
515      * is the same connection and ignores the second close.
516      *
517      * @param statusCode the status code of the close frame, {@link #NO_STATUS_CODE} for a close frame without
518      *        status code, or {@link #DEFAULT_STATUS_CODE} for the default of the adapter
519      * @param reason the reason of the close frame
520      * @return {@code false} if this failed, so that no answer of the server has to be waited for
521      */
522     private boolean closeSessions(final int statusCode, final String reason) {
523         if (webSocketImpl_ == null) {
524             return false;
525         }
526 
527         boolean success = true;
528         try {
529             if (statusCode == DEFAULT_STATUS_CODE) {
530                 webSocketImpl_.closeOutgoingSession();
531             }
532             else {
533                 webSocketImpl_.closeOutgoingSession(statusCode, reason);
534             }
535         }
536         catch (final Exception e) {
537             LOG.error("WS close error - outgoingSession_.close() failed", e);
538             success = false;
539         }
540 
541         try {
542             webSocketImpl_.closeIncomingSession();
543         }
544         catch (final Exception e) {
545             LOG.error("WS close error - incomingSession_.close() failed", e);
546             success = false;
547         }
548         return success;
549     }
550 
551     private void releaseClient() {
552         if (webSocketImpl_ == null) {
553             return;
554         }
555 
556         try {
557             webSocketImpl_.closeClient();
558         }
559         catch (final Exception e) {
560             LOG.error("WS close error - closeClient() failed", e);
561         }
562     }
563 
564     private void shutdownAdapter() {
565         closeSessions();
566         releaseClient();
567     }
568 
569     /**
570      * The WebIDL conversion to {@code [Clamp] unsigned short}: NaN is 0, the value is clamped to 0..65535
571      * and rounded to the nearest integer, ties going to the even one (so 1000.5 is 1000).
572      *
573      * @param number the number to convert
574      * @return the converted value
575      */
576     private static int clampToUnsignedShort(final double number) {
577         if (Double.isNaN(number)) {
578             return 0;
579         }
580         return (int) Math.rint(Math.min(Math.max(number, 0d), 65535d));
581     }
582 
583     private static boolean isValidCloseCode(final int code) {
584         return code == CLOSE_NORMAL || (code >= 3000 && code <= 4999);
585     }
586 
587     /**
588      * Returns the USVString of the given text: every unpaired surrogate is replaced by U+FFFD.
589      *
590      * @param text the text
591      * @return the text itself if there is nothing to replace, a converted copy otherwise
592      */
593     private static String toUsvString(final String text) {
594         StringBuilder result = null;
595         for (int i = 0; i < text.length(); i++) {
596             final char c = text.charAt(i);
597             if (Character.isHighSurrogate(c) && i + 1 < text.length()
598                     && Character.isLowSurrogate(text.charAt(i + 1))) {
599                 if (result != null) {
600                     result.append(c).append(text.charAt(i + 1));
601                 }
602                 i++;
603             }
604             else if (Character.isSurrogate(c)) {
605                 if (result == null) {
606                     result = new StringBuilder(text.length()).append(text, 0, i);
607                 }
608                 result.append('\uFFFD');
609             }
610             else if (result != null) {
611                 result.append(c);
612             }
613         }
614         return result == null ? text : result.toString();
615     }
616 
617     /**
618      * Returns the length in bytes of the UTF-8 encoding of the USVString of the given text. An unpaired
619      * surrogate is replaced by U+FFFD, which needs three bytes (String.getBytes would use one byte for '?').
620      *
621      * @param text the text
622      * @return the number of bytes
623      */
624     private static int usvUtf8Length(final String text) {
625         int length = 0;
626         for (int i = 0; i < text.length(); i++) {
627             final char c = text.charAt(i);
628             if (c < 0x80) {
629                 length += 1;
630             }
631             else if (c < 0x800) {
632                 length += 2;
633             }
634             else if (Character.isHighSurrogate(c) && i + 1 < text.length()
635                     && Character.isLowSurrogate(text.charAt(i + 1))) {
636                 length += 4;
637                 i++;
638             }
639             else {
640                 // BMP character, or an unpaired surrogate that becomes U+FFFD
641                 length += 3;
642             }
643         }
644         return length;
645     }
646 
647     /**
648      * Returns a copy of the bytes of the given binary data: the content of an ArrayBuffer, the part of its
649      * buffer a typed array or DataView views (offset and length, not the whole buffer), or the content of
650      * a Blob.
651      *
652      * @param content the argument of send()
653      * @return the copy, or {@code null} if the content is not binary data
654      */
655     private static byte[] copyOfBinaryData(final Object content) {
656         if (content instanceof NativeArrayBuffer buffer) {
657             final byte[] bytes = new byte[buffer.getLength()];
658             buffer.getByteBuffer().position(0).get(bytes);
659             return bytes;
660         }
661         if (content instanceof NativeArrayBufferView view) {
662             final byte[] bytes = new byte[view.getByteLength()];
663             view.getBuffer().getByteBuffer().position(view.getByteOffset()).get(bytes);
664             return bytes;
665         }
666         if (content instanceof Blob blob) {
667             return blob.getBytes().clone();
668         }
669         return null;
670     }
671 
672     /**
673      * Returns the number of bytes send() has to count for the given data: the byte length of an ArrayBuffer or
674      * a view on it, the size of a Blob and, for everything else, the UTF-8 length of the converted string.
675      *
676      * @param content the argument of send()
677      * @return the number of bytes
678      */
679     private static long byteLengthOf(final Object content) {
680         if (content instanceof NativeArrayBuffer buffer) {
681             return buffer.getLength();
682         }
683         if (content instanceof NativeArrayBufferView view) {
684             return view.getByteLength();
685         }
686         if (content instanceof Blob blob) {
687             return blob.getSize();
688         }
689         return usvUtf8Length(JavaScriptEngine.toString(content));
690     }
691 
692     /**
693      * The handshake is finished: checks the sub-protocol the server selected, switches to OPEN and fires the
694      * open event. A server that selects a protocol that was not offered makes the connection fail.
695      */
696     private void connectionOpened() {
697         String selected = webSocketImpl_.getSelectedProtocol();
698         if (selected == null) {
699             selected = "";
700         }
701 
702         if (!selected.isEmpty() && !requestedProtocols_.contains(selected)) {
703             // the connection fails (error + close 1006) and must not stay open; the server is told about
704             // the protocol error (1002)
705             closeSessions(CLOSE_PROTOCOL_ERROR, "");
706             connectionEnded(CLOSE_ABNORMAL, "", true);
707             return;
708         }
709 
710         if (!switchToOpen()) {
711             // close() was called while the handshake was in flight: the connection must not stay open
712             // (the first shutdown may have run before there was a session) and no open event is fired
713             shutdownAdapter();
714             return;
715         }
716 
717         // set before the open event, the handlers read it
718         protocol_ = selected;
719         fireOpenEvent();
720     }
721 
722     private void fireOpenEvent() {
723         final AbstractJavaScriptEngine<?> engine = containingPage_.getWebClient().getJavaScriptEngine();
724         if (engine == null) {
725             return;
726         }
727 
728         engine.getContextFactory().call(cx -> {
729             final Event openEvent = new Event(Event.TYPE_OPEN);
730             openEvent.setParentScope(getParentScope());
731             openEvent.setPrototype(getPrototype(openEvent.getClass()));
732             openEvent.setSrcElement(WebSocket.this);
733             openEvent.setTarget(WebSocket.this);
734             executeEventLocally(openEvent);
735 
736             return null;
737         });
738     }
739 
740     /**
741      * Ends the connection: switches to CLOSED and fires the close event (preceded by an error event if the
742      * connection failed). May be called from any thread and any number of times, only the first call fires.
743      * A connection that never was established, or that was aborted by close() while connecting, has failed
744      * whatever the caller reports: error event, close event with code 1006 and an empty reason.
745      *
746      * @param statusCode the status code reported by the adapter
747      * @param reason the reason reported by the adapter
748      * @param reportedAsFailure whether the adapter reported an error
749      */
750     private void connectionEnded(final int statusCode, final String reason, final boolean reportedAsFailure) {
751         final int previous = switchToClosed();
752         if (previous == CLOSED) {
753             return;
754         }
755 
756         // The connection is over, release the client. close() does not do this for an open connection,
757         // because stopping the client right after the close frame was sent cuts the closing handshake.
758         // Not on this thread: it may be one of the adapter's own, and stopping a client from its own
759         // threads can stall.
760         releaseClientAsync();
761 
762         // never established or aborted while connecting: failed, whatever the adapter reports
763         final boolean failed = reportedAsFailure || previous == CONNECTING || previous == ABORTED;
764 
765         final AbstractJavaScriptEngine<?> engine = containingPage_.getWebClient().getJavaScriptEngine();
766         if (engine == null) {
767             return;
768         }
769 
770         engine.getContextFactory().call(cx -> {
771             if (failed) {
772                 final Event errorEvent = new Event(Event.TYPE_ERROR);
773                 errorEvent.setParentScope(getParentScope());
774                 errorEvent.setPrototype(getPrototype(errorEvent.getClass()));
775                 errorEvent.setSrcElement(WebSocket.this);
776                 errorEvent.setTarget(WebSocket.this);
777                 executeEventLocally(errorEvent);
778             }
779 
780             final CloseEvent closeEvent = new CloseEvent();
781             closeEvent.setParentScope(getParentScope());
782             closeEvent.setPrototype(getPrototype(closeEvent.getClass()));
783             closeEvent.setCode(failed ? CLOSE_ABNORMAL : statusCode);
784             closeEvent.setReason(failed || reason == null ? "" : reason);
785             closeEvent.setWasClean(wasClean(failed, statusCode));
786             closeEvent.setTarget(WebSocket.this);
787             executeEventLocally(closeEvent);
788 
789             return null;
790         });
791     }
792 
793     /**
794      * Releases the client of the adapter without blocking the calling thread.
795      * <p>
796      * The release is handed to the executor of the {@link WebClient}, the one the adapter uses for its own
797      * work. It must not run on the calling thread: that may be one of the adapter's threads, and stopping
798      * a client from its own threads can stall. If the executor rejects the task because it is already shut
799      * down (the {@link WebClient} is closing), the release runs on a short-lived daemon thread instead, so
800      * it is never lost.
801      * </p>
802      * <p>
803      * {@link #releaseClient()} logs and swallows its own failures, so there is nothing to report here.
804      * </p>
805      */
806     private void releaseClientAsync() {
807         try {
808             containingPage_.getWebClient().getExecutor().execute(this::releaseClient);
809         }
810         catch (final RejectedExecutionException e) {
811             // the executor is already shut down, which means the WebClient is closing: don't lose the release
812             final Thread thread = new Thread(this::releaseClient, "WebSocket-release");
813             thread.setDaemon(true);
814             thread.start();
815         }
816     }
817 
818     /**
819      * Runs the task later on the JS thread of the page, like the task queue of a browser does. Used for
820      * events that have to follow the running script, e.g. after close() while connecting.
821      *
822      * @param task the task
823      */
824     private void queueTask(final Runnable task) {
825         final JavaScriptJob job = new BasicJavaScriptJob() {
826             @Override
827             public void run() {
828                 task.run();
829             }
830         };
831         containingPage_.getEnclosingWindow().getJobManager().addJob(job, containingPage_);
832     }
833 
834     /**
835      * Returns the current state of the connection.
836      * Possible values are {@link #CONNECTING}, {@link #OPEN}, {@link #CLOSING}, or {@link #CLOSED}.
837      *
838      * @return the current ready state
839      */
840     @JsxGetter
841     public int getReadyState() {
842         final int state = readyState_.get();
843         return state == ABORTED ? CLOSING : state;
844     }
845 
846     /**
847      * Returns the URL of the WebSocket connection.
848      *
849      * @return the URL string
850      */
851     @JsxGetter
852     public String getUrl() {
853         if (url_ == null) {
854             throw JavaScriptEngine.typeError("invalid call");
855         }
856         return url_.toString();
857     }
858 
859     /**
860      * Returns the sub-protocol in use, or an empty string if no sub-protocol was selected.
861      *
862      * @return the sub-protocol
863      */
864     @JsxGetter
865     public String getProtocol() {
866         return protocol_;
867     }
868 
869     /**
870      * Returns the extensions negotiated with the server, or an empty string if no extensions are in use.
871      * The adapter does not report the negotiated extensions, so this is always an empty string.
872      *
873      * @return the extensions in use
874      */
875     @JsxGetter
876     public String getExtensions() {
877         return "";
878     }
879 
880     /**
881      * Returns the number of bytes of data that have been queued but not yet transmitted.
882      * Once the connection is closing or closed this value only increases: every send() adds the size of its
883      * data, which is discarded. Data sent while the connection is open is handed over to the adapter at once
884      * and is not counted, because the adapter does not tell when the data has left.
885      *
886      * @return the buffered amount in bytes
887      */
888     @JsxGetter
889     public long getBufferedAmount() {
890         return bufferedAmount_.get();
891     }
892 
893     /**
894      * Returns the binary data type used when receiving binary messages.
895      *
896      * @return the binary type ({@code "blob"} or {@code "arraybuffer"})
897      */
898     @JsxGetter
899     public String getBinaryType() {
900         return binaryType_;
901     }
902 
903     /**
904      * Sets the binary data type used when receiving binary messages.
905      *
906      * @param type the new binary type; must be {@code "blob"} or {@code "arraybuffer"}
907      */
908     @JsxSetter
909     public void setBinaryType(final String type) {
910         if ("arraybuffer".equals(type) || "blob".equals(type)) {
911             binaryType_ = type;
912         }
913     }
914 
915     /**
916      * {@inheritDoc}
917      */
918     @Override
919     public void close() throws IOException {
920         // called when the page is unloaded: whatever the state is, the client has to be released.
921         // No events are fired here and the state does not reach CLOSED, the page is gone.
922         if (switchToClosing() != NOT_CLOSABLE) {
923             closeSessions();
924         }
925         releaseClient();
926     }
927 
928     /**
929      * Closes the WebSocket connection or connection attempt, if any.
930      * If the connection is already {@link #CLOSING} or {@link #CLOSED}, this method does nothing.
931      * The arguments are validated first, whatever the state is: a code that is neither 1000 nor in the range
932      * 3000 to 4999 results in an {@code InvalidAccessError}, a reason longer than 123 bytes (UTF-8) in a
933      * {@code SyntaxError}.
934      *
935      * @param code a numeric value indicating the status code explaining why the connection is being closed
936      * @param reason a human-readable string explaining why the connection is closing
937      */
938     @JsxFunction
939     public void close(final Object code, final Object reason) {
940         int statusCode = NO_STATUS_CODE;
941         if (!JavaScriptEngine.isUndefined(code)) {
942             statusCode = clampToUnsignedShort(JavaScriptEngine.toNumber(code));
943             if (!isValidCloseCode(statusCode)) {
944                 throw JavaScriptEngine.asJavaScriptException(
945                         getWindow(),
946                         "WebSocket Error: the close code must be 1000 or in the range 3000 to 4999.",
947                         DOMException.INVALID_ACCESS_ERR);
948             }
949         }
950 
951         String closeReason = "";
952         if (!JavaScriptEngine.isUndefined(reason)) {
953             final String usvReason = toUsvString(JavaScriptEngine.toString(reason));
954             if (usvUtf8Length(usvReason) > MAX_CLOSE_REASON_BYTES) {
955                 throw JavaScriptEngine.asJavaScriptException(
956                         getWindow(),
957                         "WebSocket Error: the close reason must not be longer than 123 bytes.",
958                         DOMException.SYNTAX_ERR);
959             }
960             if (statusCode != NO_STATUS_CODE) {
961                 // a reason can only be sent together with a status code
962                 closeReason = usvReason;
963             }
964         }
965 
966         final int previous = switchToClosing();
967         if (previous == NOT_CLOSABLE) {
968             return;
969         }
970 
971         if (previous == CONNECTING) {
972             // there is no connection to wait for
973             shutdownAdapter();
974         }
975         else if (closeSessions(statusCode, closeReason)) {
976             // OPEN: the closing handshake is under way. The adapter reports the end of the connection with the
977             // status code of the server, and connectionEnded() releases the client then.
978             return;
979         }
980         else {
981             // the close frame could not be sent, so there is no answer to wait for
982             releaseClient();
983         }
984 
985         // the adapter has nothing to report for a cancelled attempt: fail the connection ourselves, after
986         // the running script (error + close 1006)
987         queueTask(() -> connectionEnded(CLOSE_ABNORMAL, "", true));
988     }
989 
990     /**
991      * Transmits data to the server over the WebSocket connection.
992      * Throws an {@code InvalidStateError} while {@link #CONNECTING}; the data is discarded when
993      * {@link #CLOSING} or {@link #CLOSED}. Anything that is not binary
994      * is converted to a string and sent as text message. Binary data (an ArrayBuffer, a typed array or
995      * DataView, which contribute only the part they view, or a Blob) is sent as binary message; it is copied
996      * before this method returns, so later changes of the buffer do not change what is sent.
997      *
998      * @param content the data to send
999      */
1000     @JsxFunction
1001     public void send(final Object content) {
1002         final int state = readyState_.get();
1003         if (state == CONNECTING) {
1004             throw JavaScriptEngine.asJavaScriptException(
1005                     getWindow(),
1006                     "WebSocket Error: send() is not allowed while the connection is CONNECTING.",
1007                     DOMException.INVALID_STATE_ERR);
1008         }
1009         if (state != OPEN) {
1010             // CLOSING or CLOSED: the data is discarded, but its size is added to bufferedAmount
1011             bufferedAmount_.addAndGet(byteLengthOf(content));
1012             return;
1013         }
1014 
1015         try {
1016             final byte[] bytes = copyOfBinaryData(content);
1017             if (bytes != null) {
1018                 webSocketImpl_.send(ByteBuffer.wrap(bytes));
1019                 return;
1020             }
1021 
1022             // everything else is sent as text, converted like a USVString: ToString (so 42, null, undefined
1023             // and objects become "42", "null", "undefined" and "[object Object]") and every unpaired
1024             // surrogate replaced by U+FFFD (the UTF-8 encoder of Java would write '?' for it)
1025             webSocketImpl_.send(toUsvString(JavaScriptEngine.toString(content)));
1026         }
1027         catch (final IOException e) {
1028             LOG.error("WS send error", e);
1029         }
1030     }
1031 
1032     /**
1033      * Bridges the callbacks of the {@link WebSocketAdapter} to this WebSocket: it switches the ready state and
1034      * fires the events at the scripts. The callbacks come from the threads of the adapter.
1035      */
1036     private final class AdapterListener implements WebSocketListener {
1037 
1038         private final VarScope scope_;
1039 
1040         AdapterListener(final VarScope scope) {
1041             scope_ = scope;
1042         }
1043 
1044         @Override
1045         public void onWebSocketConnecting() {
1046             // nothing to do, CONNECTING is the initial state and is never entered again
1047         }
1048 
1049         @Override
1050         public void onWebSocketOpen() {
1051             connectionOpened();
1052         }
1053 
1054         @Override
1055         public void onWebSocketClose(final int statusCode, final String reason) {
1056             connectionEnded(statusCode, reason, false);
1057         }
1058 
1059         @Override
1060         public void onWebSocketText(final String message) {
1061             if (readyState_.get() != OPEN) {
1062                 // messages received after close() was called are dropped
1063                 return;
1064             }
1065 
1066             final AbstractJavaScriptEngine<?> engine = containingPage_.getWebClient().getJavaScriptEngine();
1067             if (engine != null) {
1068                 engine.getContextFactory().call(cx -> {
1069                     final MessageEvent msgEvent = new MessageEvent(message);
1070                     msgEvent.setParentScope(scope_);
1071                     msgEvent.setPrototype(getPrototype(msgEvent.getClass()));
1072                     if (origin_ != null) {
1073                         msgEvent.setOrigin(origin_);
1074                     }
1075                     msgEvent.setSrcElement(WebSocket.this);
1076                     msgEvent.setTarget(WebSocket.this);
1077                     executeEventLocally(msgEvent);
1078 
1079                     return null;
1080                 });
1081             }
1082         }
1083 
1084         @Override
1085         public void onWebSocketBinary(final ByteBuffer payload) {
1086             if (readyState_.get() != OPEN) {
1087                 // messages received after close() was called are dropped
1088                 return;
1089             }
1090 
1091             final AbstractJavaScriptEngine<?> engine = containingPage_.getWebClient().getJavaScriptEngine();
1092             if (engine != null) {
1093                 engine.getContextFactory().call(cx -> {
1094                     final MessageEvent msgEvent;
1095 
1096                     if ("blob".equals(binaryType_)) {
1097                         final byte[] bytes = new byte[payload.remaining()];
1098                         payload.get(bytes);
1099                         final Blob blob = new Blob(bytes, "application/octet-stream");
1100 
1101                         blob.setParentScope(getParentScope());
1102                         blob.setPrototype(ScriptableObject.getClassPrototype(getParentScope(),
1103                                             blob.getClassName()));
1104 
1105                         msgEvent = new MessageEvent(blob);
1106                     }
1107                     else {
1108                         final NativeArrayBuffer buffer = new NativeArrayBuffer(payload.remaining());
1109                         buffer.getByteBuffer().put(payload);
1110 
1111                         buffer.setParentScope(getParentScope());
1112                         buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(),
1113                                                 buffer.getClassName()));
1114 
1115                         msgEvent = new MessageEvent(buffer);
1116                     }
1117 
1118                     msgEvent.setParentScope(scope_);
1119                     msgEvent.setPrototype(getPrototype(msgEvent.getClass()));
1120                     if (origin_ != null) {
1121                         msgEvent.setOrigin(origin_);
1122                     }
1123                     msgEvent.setSrcElement(WebSocket.this);
1124                     msgEvent.setTarget(WebSocket.this);
1125                     executeEventLocally(msgEvent);
1126 
1127                     return null;
1128                 });
1129             }
1130         }
1131 
1132         @Override
1133         public void onWebSocketConnectError(final Throwable cause) {
1134             if (LOG.isErrorEnabled()) {
1135                 LOG.error("WS connect error for url '" + url_ + "':", cause);
1136             }
1137             onWebSocketError(cause);
1138         }
1139 
1140         @Override
1141         public void onWebSocketError(final Throwable cause) {
1142             connectionEnded(CLOSE_ABNORMAL, "", true);
1143         }
1144     }
1145 }