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 }