@bokeh/bokehjs
Version:
Interactive, novel data visualization
286 lines • 11.9 kB
JavaScript
import { ClientReconnected, ConnectionLost } from "../core/bokeh_events";
import { logger } from "../core/logging";
import { Document } from "../document";
import { Message } from "../protocol/message";
import { Receiver } from "../protocol/receiver";
import { ClientSession } from "./session";
import { assert } from "../core/util/assert";
export const DEFAULT_SERVER_WEBSOCKET_URL = "ws://localhost:5006/ws";
export const DEFAULT_TOKEN = "eyJzZXNzaW9uX2lkIjogImRlZmF1bHQifQ";
const MAX_RECONNECTION_ATTEMPTS = 5;
const RECONNECT_BASE_DELAY = 1000;
let _connection_count = 0;
export function parse_token(token) {
let payload = token.split(".")[0];
const mod = payload.length % 4;
if (mod != 0) {
payload = payload + "=".repeat(4 - mod);
}
return JSON.parse(atob(payload.replace(/_/g, "/").replace(/-/g, "+")));
}
// WebSocket close event is emitted before page is destroyed, resulting in an
// unnecessary reconnect attempt and a UI notification just before page reloads.
let _prevent_reconnect = false;
addEventListener("beforeunload", () => {
_prevent_reconnect = true;
});
export class ClientConnection {
url;
token;
args_string;
static __name__ = "ClientConnection";
_number = _connection_count++;
socket = null;
session = null;
closed_permanently = false;
id;
_reconnection_attempts_left = MAX_RECONNECTION_ATTEMPTS;
get reconnection_attempts() {
return MAX_RECONNECTION_ATTEMPTS - this._reconnection_attempts_left;
}
_current_handler = null;
_pending_replies = new Map();
_pending_messages = [];
_receiver = new Receiver();
constructor(url = DEFAULT_SERVER_WEBSOCKET_URL, token = DEFAULT_TOKEN, args_string = null) {
this.url = url;
this.token = token;
this.args_string = args_string;
this.id = parse_token(token).session_id.split(".")[0];
logger.debug(`Creating websocket ${this._number} to '${this.url}' session '${this.id}'`);
}
async reconnect() {
this._try_reconnect(true);
}
async connect() {
if (this.closed_permanently) {
throw new Error("Cannot connect() a closed ClientConnection");
}
if (this.socket != null) {
throw new Error("Already connected");
}
this._current_handler = null;
this._pending_replies.clear();
this._pending_messages = [];
try {
let versioned_url = `${this.url}`;
if (this.args_string != null && this.args_string.length > 0) {
versioned_url += `?${this.args_string}`;
}
this.socket = new WebSocket(versioned_url, ["bokeh", this.token]);
return new Promise((resolve, reject) => {
assert(this.socket != null);
// "arraybuffer" gives us binary data we can look at;
// if we just needed an opaque blob we could use "blob"
this.socket.binaryType = "arraybuffer";
this.socket.onopen = () => this._on_open(resolve, reject);
this.socket.onmessage = (event) => this._on_message(event);
this.socket.onclose = (event) => this._on_close(event, reject);
this.socket.onerror = () => this._on_error(reject);
});
}
catch (error) {
logger.error(`websocket creation failed to url: ${this.url}`);
logger.error(` - ${error}`);
throw error;
}
}
close() {
if (!this.closed_permanently) {
logger.debug(`Permanently closing websocket connection ${this._number}`);
this.closed_permanently = true;
if (this.socket != null) {
this.socket.close(1000, `close method called on ClientConnection ${this._number}`);
}
this.session._connection_closed();
}
}
_try_reconnect(force = false) {
if (this.closed_permanently) {
logger.info(`Websocket connection ${this._number} permanently disconnected, will not attempt to reconnect`);
}
else if (!force && this._reconnection_attempts_left <= 0) {
logger.info(`Websocket connection ${this._number} disconnected, will not attempt to automatically reconnect`);
}
else {
if (this.socket?.readyState !== WebSocket.OPEN && this.socket?.readyState !== WebSocket.CONNECTING) {
this._reconnection_attempts_left -= 1;
logger.debug(`Attempting to reconnect websocket ${this._number}, ${this._reconnection_attempts_left} attempts left`);
this.connect().then(() => {
logger.info(`Reconnected websocket ${this._number}`);
this._reconnection_attempts_left = MAX_RECONNECTION_ATTEMPTS;
this.session?.document.event_manager.send_event(new ClientReconnected());
}).catch(err => {
logger.debug(`Could not reconnect ${this._number}, ${err}`);
});
}
}
}
_schedule_reconnect(milliseconds) {
if (this.session == null) {
return;
}
const { document } = this.session;
const should_reconnect = document.config.reconnect_session && this._reconnection_attempts_left > 0;
const timeout = should_reconnect ? milliseconds : null;
const event = new ConnectionLost(new WeakRef(this), this.reconnection_attempts, timeout);
document.event_manager.send_event(event);
if (should_reconnect) {
setTimeout(() => this._try_reconnect(), milliseconds);
}
}
send(message) {
if (this.socket != null) {
message.send(this.socket);
}
else {
logger.error("not connected so cannot send", message);
}
}
async send_with_reply(message) {
const reply = await new Promise((resolve, reject) => {
this._pending_replies.set(message.msgid(), { resolve, reject });
this.send(message);
});
if (reply.msgtype() == "ERROR") {
throw new Error(`Error reply ${reply.content.text}`);
}
else {
return reply;
}
}
async _pull_doc_json() {
const message = Message.create("PULL-DOC-REQ", {}, {});
const reply = await this.send_with_reply(message);
if (!("doc" in reply.content)) {
throw new Error("No 'doc' field in PULL-DOC-REPLY");
}
return { doc_json: reply.content.doc, buffers: reply.buffers };
}
async _repull_session_doc(resolve, reject) {
logger.debug(this.session != null ? "Repulling session" : "Pulling session for first time");
try {
const { doc_json, buffers } = await this._pull_doc_json();
if (this.session == null) {
if (this.closed_permanently) {
logger.debug("Got new document after connection was already closed");
reject(new Error("The connection has been closed"));
}
else {
const events = [];
const document = Document.from_json(doc_json, events, buffers);
this.session = new ClientSession(this, document);
// Send back change events that happened during model initialization.
for (const event of events) {
document._trigger_on_change(event);
}
for (const msg of this._pending_messages) {
this.session.handle(msg);
}
this._pending_messages = [];
logger.debug("Created a new session from new pulled doc");
resolve(this.session);
}
}
else {
this.session.document.replace_with_json(doc_json);
logger.debug("Updated existing session with new pulled doc");
resolve(this.session);
}
}
catch (error) {
console.trace(error);
logger.error(`Failed to repull session ${error}`);
reject(error instanceof Error ? error : `${error}`);
}
}
_on_open(resolve, reject) {
logger.info(`Websocket connection ${this._number} is now open`);
this._current_handler = (message) => {
this._awaiting_ack_handler(message, resolve, reject);
};
}
_on_message(event) {
if (this._current_handler == null) {
logger.error("Got a message with no current handler set");
}
try {
this._receiver.consume(event.data);
}
catch (e) {
this._close_bad_protocol(`${e}`);
}
const msg = this._receiver.message;
if (msg != null) {
const problem = msg.problem();
if (problem != null) {
this._close_bad_protocol(problem);
}
this._current_handler(msg);
}
}
/**
* The reconnect delay exponentially increases after each attempt. The
* first attempt is done immediately.
*/
_reconnect_delay() {
const retries = MAX_RECONNECTION_ATTEMPTS - this._reconnection_attempts_left;
return retries == 0 ? 0 : RECONNECT_BASE_DELAY * 2 ** retries;
}
_on_close(event, reject) {
logger.info(`Lost websocket ${this._number} connection, ${event.code} (${event.reason})`);
this.socket = null;
this._pending_replies.forEach((pr) => pr.reject("Disconnected"));
this._pending_replies.clear();
if (!this.closed_permanently && !_prevent_reconnect) {
logger.debug(`Pending schedule_reconnect for ${this._number}`);
this._schedule_reconnect(this._reconnect_delay());
}
reject(new Error(`Lost websocket connection, ${event.code} (${event.reason})`));
}
_on_error(reject) {
logger.debug(`Websocket error on socket ${this._number}`);
const msg = "Could not open websocket";
logger.error(`Failed to connect to Bokeh server: ${msg}`);
reject(new Error(msg));
}
_close_bad_protocol(detail) {
logger.error(`Closing connection: ${detail}`);
if (this.socket != null) {
this.socket.close(1002, detail);
} // 1002 = protocol error
}
_awaiting_ack_handler(message, resolve, reject) {
if (message.msgtype() === "ACK") {
this._current_handler = (message) => this._steady_state_handler(message);
// Reload any sessions
void this._repull_session_doc(resolve, reject);
}
else {
this._close_bad_protocol("First message was not an ACK");
}
}
_steady_state_handler(message) {
const reqid = message.reqid();
const pr = this._pending_replies.get(reqid);
if (pr != null) {
this._pending_replies.delete(reqid);
pr.resolve(message);
}
else if (this.session != null) {
this.session.handle(message);
}
else if (message.msgtype() != "PATCH-DOC") {
// This branch can be executed only before we get the document.
// When we get the document, all of the patches will already be incorporated.
// In general, it's not possible to apply patches received before the document,
// since they may change some models that were removed before serving the document.
this._pending_messages.push(message);
}
}
}
export function pull_session(url, token, args_string) {
const connection = new ClientConnection(url, token, args_string);
return connection.connect();
}
//# sourceMappingURL=connection.js.map