' WebSocketClient.brs
' Copyright (C) 2018 Rolando Islas
' Released under the MIT license
'
' BrightScript web socket client (RFC 6455)
'
' JellyRock modifications (vendored — see ../../README.md; diff vs the pinned upstream SHA):
' - Removed all TLS/wss:// support (TlsUtil.brs, ByteUtil.rsa_encrypt, m._secure/m._tls
' branches). Upstream's TLS was an unimplemented skeleton (stubbed asymmetric crypto,
' no cert validation) and Roku cannot do socket TLS regardless — JellyRock is ws:// only.
' Create a new WebSocketClient instance
function WebSocketClient() as object
ws = {}
' Constants
ws.STATE = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
}
ws.OPCODE = {
CONTINUATION: 0,
TEXT: 1,
BINARY: 2,
CLOSE: 8,
PING: 9,
PONG: 10
}
ws._REGEX_URL = createObject("roRegex", "(\w+):\/\/([^/:]+)(?::(\d+))?(.*)?", "")
ws._CHARS = "0123456789abcdefghijklmnopqrstuvwxyz".split("")
ws._NL = chr(13) + chr(10)
ws._HTTP_STATUS_LINE_REGEX = createObject("roRegex", "(HTTP\/\d+(?:.\d)?)\s(\d{3})\s(.*)", "")
ws._HTTP_HEADER_REGEX = createObject("roRegex", "(\w+):\s?(.*)", "")
ws._WS_ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
ws._FRAME_SIZE = 1024
ws._CLOSING_DELAY = 30
ws._BUFFER_SOCKET_SIZE = 4096 ' we allow 4times higher amount in buffer as max limit without waiting to clear it, figured out the last amount of buffer is about 13000 before it overload
ws._BUFFER_SLEEP = 10
ws._BUFFER_LOOP_LIMIT = 5000 / ws._BUFFER_SLEEP ' max waiting time till try to push another value into buffer, _BUFFER_SLEEP * _BUFFER_LOOP_LIMIT
' Variables
ws._logger = Logger()
ws._ready_state = ws.state.CLOSED
ws._protocols = []
ws._headers = []
ws._socket = invalid
ws._sec_ws_key = invalid
ws._handshake = invalid
ws._sent_handshake = false
ws._has_received_handshake = false
ws._buffer_size = cint(1024 * 1024 * 0.5)
ws._data = createObject("roByteArray")
ws._data[ws._buffer_size] = 0
ws._data_size = 0
ws._frame_data = createObject("roByteArray")
ws._started_closing = 0
ws._hostname = ""
ws._ws_port = createObject("roMessagePort")
ws._message_port = invalid
' ========== Getters ==========
' Get web socket ready state
' @see WebSocketClient.STATE for values
' @param self WebSocketClient
' @return integer ready state
ws.get_ready_state = function() as integer
return m._ready_state
end function
' Get web socket protocols sent on initial handshake
' @param self WebSocketClient
' @return roArray web socket protocols
ws.get_protocols = function() as object
return m._protocols
end function
' Get HTTP headers sent on initial HTTP request
' @param self WebSocketClient
' @return roArray HTTP headers - even indices are the header keys, the odd
' indices are the header values
ws.get_headers = function() as object
return m._headers
end function
' Get the maximum buffer size for data sent to the websocket
' @param self WebSocketClient
' @return integer max buffer size in bytes
ws.get_buffer_size = function() as integer
return m._buffer_size
end function
' ========== Setters ==========
' Set the web socket protocols to request from the web socket server
' @param self WebSocketClient
' @param roArray of protocol strings
ws.set_protocols = sub(protocols as object)
m._protocols = protocols
m._post_message("protocols", m._protocols)
end sub
' Set the headers to send on the initial HTTP request for a web socket
' @param self WebSocketClient
' @param roArray of header strings - The even indices should be the header
' keys and the odd are the header values
ws.set_headers = sub(headers as object)
m._headers = headers
m._post_message("headers", m._headers)
end sub
' Set the buffer size for web socket data
' @param self WebSocketClient
' @param size integer max size of buffer in bytes
ws.set_buffer_size = sub(size as integer)
if m._ready_state <> m.STATE.CLOSED
m._logger.printl(m.WARN, "WebSocketClient: Cannot resize buffer on a socket that is not closed")
return
else if size < m._FRAME_SIZE
m._logger.printl(m.WARN, "WebSocketClient: Cannot set buffer to a size smaller than " + m._FRAME_SIZE.toStr() + " bytes")
return
end if
m._buffer_size = size
m._buffer = createObject("roByteArray")
m._buffer[m._buffer_size] = 0
m._data_size = 0
m._post_message("buffer_size", m._buffer_size)
end sub
' Set the message port that should be used to relay web socket events and
' field change updates
' @param self WebSocketClient
' @param port roMessagePort
ws.set_message_port = sub(port as object)
m._message_port = port
end sub
' Set the log level
ws.set_log_level = sub(log_level as string)
m._logger.set_log_level(log_level)
end sub
' ========== Main ==========
' Parses one websocket frame or HTTP message, if one is available
' @param self WebSocketClient
ws.run = sub()
msg = wait(200, m._ws_port)
' Socket event
if type(msg) = "roSocketEvent"
m._send_handshake()
m._read_socket_data()
end if
m._try_force_close()
end sub
' Force close a connection after the close frame has been sent and no
' response was given, after a timeout
' @param self WebSocketClient
ws._try_force_close = sub()
if m._ready_state = m.STATE.CLOSING and uptime(0) - m._started_closing >= m._CLOSING_DELAY
m._close()
end if
end sub
' Sends data through the socket
' @param self WebSocketClient
' @param message array - should contain one element of type roString or roArray
' cannot exceed 125 bytes if the the specified opcode
' is for a control frame
' @param _opcode int - define a **control** opcode data opcodes are determined
' by the type of the passed message
' @param silent boolean - does not send on_error event
' @param do_close boolean - if true, a close frame will be sent on errors
' @return integer amount of bytes sent
ws.send = function(message as dynamic, _opcode = -1 as integer, silent = false as boolean, do_close = true as boolean) as integer
if m._socket = invalid or not m._socket.isWritable()
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: socket is closed")
return -1
end if
if m._ready_state <> m.STATE.OPEN
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: connection not open")
return -1
end if
if type(message) = "roString" or type(message) = "String" or type(message) = "roByteArray"
message = [message]
end if
if message.count() <> 1
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: too many parameters")
return -1
end if
bytes = createObject("roByteArray")
opcode = 0
if type(message[0]) = "roString" or type(message[0]) = "String"
bytes.fromAsciiString(message[0])
opcode = m.OPCODE.TEXT
else if type(message[0]) = "roArray" or type(message[0]) = "roByteArray"
for each byte in message[0]
bytes.push(byte)
end for
opcode = m.OPCODE.BINARY
else
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: invalid parameter type")
return -1
end if
if _opcode > -1 and (_opcode >> 3) <> 1
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: specified opcode was not a control opcode")
return -1
else if _opcode > -1
if bytes.count() > 125
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Failed to send data: control frames cannot have a payload larger than 125 bytes")
return -1
end if
opcode = _opcode
end if
' Frame message
frame_count = bytes.count() \ m._FRAME_SIZE
if bytes.count() mod m._FRAME_SIZE <> 0 or frame_count = 0
frame_count++
end if
total_sent = 0
for frame_index = 0 to frame_count - 1
' Get sub array of payload bytes
payload = createObject("roByteArray")
max = bytes.count() - 1
if (frame_index + 1) * m._FRAME_SIZE - 1 < max
max = (frame_index + 1) * m._FRAME_SIZE - 1
end if
for byte_index = frame_index * m._FRAME_SIZE to max
payload.push(bytes[byte_index])
end for
' Construct frame
frame = createObject("roByteArray")
' FIN(1) RSV1(1) RSV2(1) RSV3(1) opcode(4)
final = 0
if frame_index = frame_count - 1
final = &h80
end if
opcode_frame = m.OPCODE.CONTINUATION
if frame_index = 0
opcode_frame = opcode
end if
frame.push(final or opcode_frame)
' mask(1) payload_length(7)
length_7 = payload.count()
if payload.count() > &hffff
length_7 = 127
else if payload.count() > 125
length_7 = 126
end if
frame.push(&h80 or length_7)
' payload_length_continuation(64)
' 16 bit uint
if length_7 = 126
frame.append(short_to_bytes(payload.count()))
' 64 bit uint
else if length_7 = 127
frame.append(long_to_bytes(payload.count()))
end if
' masking key(32)
mask = rnd(&hffff)
mask_bytes = int_to_bytes(mask)
frame.append(mask_bytes)
' Mask payload
masked_payload = createObject("roByteArray")
for byte_index = 0 to payload.count() - 1
masking_key = mask_bytes[byte_index mod 4]
byte = payload[byte_index]
masked_byte = xor(byte, masking_key)
masked_payload.push(masked_byte)
end for
frame.append(masked_payload)
' Send frame
if _opcode <> m.OPCODE.PING
m._logger.printl(m._logger.VERBOSE, "WebSocketClient: Sending frame: " + frame.toHexString())
end if
sent = m._socket.send(frame, 0, frame.count())
if _opcode <> m.OPCODE.PING
m._logger.printl(m._logger.VERBOSE, "WebSocketClient: Sent " + sent.toStr() + " bytes")
loop_wait = 0
while m._socket.GetCountSendBuf() > m._BUFFER_SOCKET_SIZE
m._logger.printl(m._logger.VERBOSE, "WebSocketClient: Sleeping " + m._BUFFER_SLEEP.toStr() + "ms to reduce buffer")
sleep(m._BUFFER_SLEEP)
if loop_wait > m._BUFFER_LOOP_LIMIT
exit while
end if
loop_wait++
end while
end if
total_sent += sent
if sent <> frame.count()
if do_close
m._close()
end if
if not silent
m._error(14, "Failed to send data")
end if
return total_sent
end if
end for
return total_sent
end function
' Send the initial websocket handshake if it has not been sent
' @param self WebSocketClient
ws._send_handshake = sub()
if m._socket = invalid or not m._socket.isWritable() or m._sent_handshake or m._handshake = invalid
return
end if
m._logger.printl(m._logger.VERBOSE, m._handshake)
sent = m._socket.sendStr(m._handshake)
m._logger.printl(m._logger.VERBOSE, "WebSocketClient: Sent " + sent.toStr() + " bytes")
if sent = -1
m._close()
m._error(4, "Failed to send data: " + m._socket.status().toStr())
return
end if
m._sent_handshake = true
end sub
' Read socket data
' @param self WebSocketClient
ws._read_socket_data = sub()
if m._socket = invalid or m._ready_state = m.STATE.CLOSED
return
end if
buffer = createObject("roByteArray")
buffer[1024] = 0
bytes_received = 0
if m._socket.isReadable() and m._socket.getCountRcvBuf() > 0
bytes_received = m._socket.receive(buffer, 0, 1024)
end if
if bytes_received < 0
m._close()
m._error(15, "Failed to read from socket")
return
end if
buffer_index = 0
for byte_index = m._data_size to m._data_size + bytes_received - 1
m._data[byte_index] = buffer[buffer_index]
buffer_index++
end for
m._data_size += bytes_received
m._data[m._data_size] = 0
' WebSocket frames
if m._has_received_handshake
' Wait for at least the payload 7-bit size
if m._data_size < 2
return
end if
final = (m._data[0] >> 7) = 1
opcode = (m._data[0] and &hf)
control = (opcode >> 3) = 1
masked = (m._data[1] >> 7) = 1
payload_size_7 = m._data[1] and &h7f
payload_size = payload_size_7
payload_index = 2
if payload_size_7 = 126
' Wait for the 16-bit payload size
if m._data_size < 4
return
end if
payload_size = bytes_to_short(m._data[2], m._data[3])
payload_index += 2
else if payload_size_7 = 127
' Wait for the 64-bit payload size
if m._data_size < 10
return
end if
payload_size = bytes_to_long(m._data[2], m._data[3], m._data[4], m._data[5], m._data[6], m._data[7], m._data[8], m._data[9])
payload_index += 8
end if
' JellyRock hardening: bound the server-declared payload size before buffering it, so a
' malicious/MITM frame on the cleartext ws:// LAN can't grow m._data unbounded (OOM/crash).
' Jellyfin command frames are a few KB at most; _buffer_size is the ceiling.
if payload_size < 0 or payload_size > m._buffer_size
m._close()
m._error(18, "Frame payload exceeds max size")
return
end if
if masked
' Wait for mask int
if m._data_size < payload_index
return
end if
' JellyRock: skip the 4 mask-key bytes. Server->client frames are never masked (RFC 6455),
' so we don't unmask — upstream read the key into a variable it never used (removed).
payload_index += 4
end if
' Wait for payload
if m._data_size < payload_index + payload_size
return
end if
payload = createObject("roByteArray")
for byte_index = payload_index to payload_index + payload_size - 1
payload.push(m._data[byte_index])
end for
' Handle control frame
if control
m._handle_frame(opcode, payload)
' Handle data frame
else if final
full_payload = createObject("roByteArray")
full_payload.append(m._frame_data)
full_payload.append(payload)
m._handle_frame(opcode, full_payload)
m._frame_data.clear()
' Check for continuation frame
else
m._frame_data.append(payload)
end if
' Save start of next frame
if m._data_size > payload_index + payload_size
data = createObject("roByteArray")
data.append(m._data)
m._data.clear()
for byte_index = payload_index + payload_size to m._data_size - 1
m._data.push(data[byte_index])
end for
else
m._data.clear()
end if
' HTTP/Handshake
else
data = m._data.toAsciiString()
http_delimiter = m._NL + m._NL
if data.len() <> data.replace(http_delimiter, "").len()
split = data.split(http_delimiter)
message = split[0]
data = ""
for split_index = 1 to split.count() - 1
data += split[split_index]
if split_index < split.count() - 1 or split[split_index].right(4) = m._NL + m._NL
data += m._NL + m._NL
end if
end for
' Handle the message
m._logger.printl(m._logger.VERBOSE, "WebSocketClient: Message: " + message)
m._handle_handshake_response(message)
end if
m._data.fromAsciiString(data)
end if
m._data_size = m._data.count()
m._data[m._buffer_size] = 0
end sub
' Handle the handshake message or die trying
' @param self WebSocketClient
' @param string http response header
ws._handle_handshake_response = sub(message as string)
lines = message.split(m._NL)
if lines.count() = 0
m._close()
m._error(5, "Invalid handshake: Missing status line")
return
end if
' Check status line
if not m._HTTP_STATUS_LINE_REGEX.isMatch(lines[0])
m._close()
m._error(6, "Invalid handshake: Status line malformed")
return
end if
status_line = m._HTTP_STATUS_LINE_REGEX.match(lines[0])
if status_line[1] <> "HTTP/1.1"
m._close()
m._error(7, "Invalid handshake: Response version mismatch. Expected HTTP/1.1, got " + status_line[0])
return
end if
if status_line[2] <> "101"
m._close()
m._error(8, "Invalid handshake: HTTP status code is not 101: Received " + status_line[2])
return
end if
' Search headers
protocol = ""
for header_line_index = 1 to lines.count() - 1:
if m._HTTP_HEADER_REGEX.isMatch(lines[header_line_index])
header = m._HTTP_HEADER_REGEX.match(lines[header_line_index])
' Upgrade
if ucase(header[1]) = "UPGRADE" and ucase(header[2]) <> "WEBSOCKET"
m._close()
m._error(9, "Invalid handshake: invalid upgrade header: " + header[2])
return
' Connection
else if ucase(header[1]) = "CONNECTION" and ucase(header[2]) <> "UPGRADE"
m._close()
m._error(10, "Invalid handshake: invalid connection header: " + header[2])
return
' Sec-WebSocket-Accept
else if ucase(header[1]) = "SEC-WEBSOCKET-ACCEPT"
expected_array = createObject("roByteArray")
expected_array.fromAsciiString(m._sec_ws_key + m._WS_ACCEPT_GUID)
digest = createObject("roEVPDigest")
digest.setup("sha1")
expected = digest.process(expected_array)
if expected <> header[2].trim()
m._close()
m._error(11, "Invalid handshake: Sec-WebSocket-Accept value is invalid: " + header[2])
return
end if
' Sec-WebSocket-Extensions
else if ucase(header[1]) = "SEC-WEBSOCKET-EXTENSIONS" and header[2] <> ""
m._close()
m._error(12, "Invalid handshake: Sec-WebSocket-Extensions value is invalid: " + header[2])
return
' Sec-WebSocket-Protocol
else if ucase(header[1]) = "SEC-WEBSOCKET-PROTOCOL"
p = header[2].trim()
was_requested = false
for each requested_protocol in m._protocols
if requested_protocol = p
was_requested = true
end if
end for
if not was_requested
m._close()
m._error(13, "Invalid handshake: Sec-WebSocket-Protocol contains a protocol that was not requested: " + p)
return
end if
protocol = p
end if
end if
end for
m._has_received_handshake = true
m._state(m.STATE.OPEN)
m._post_message("on_open", {
protocol: protocol
})
end sub
' Post a message to the message port
' @param self WebSocketClient
' @param id string message event id
' @param data dynamic message data
ws._post_message = sub(id as string, data as dynamic)
if m._message_port <> invalid
m._message_port.postMessage({
id: id,
data: data
})
end if
end sub
' Handle a web socket frame
' @param self WebSocketClient
' @param opcode int opcode
' @param payload roByteArray payload data
ws._handle_frame = sub(opcode as integer, payload as object)
if opcode <> m.OPCODE.PONG
frame_print = "WebSocketClient: " + "Received frame:" + m._NL
frame_print += " Opcode: " + opcode.toStr() + m._NL
frame_print += " Payload: " + payload.toHexString()
m._logger.printl(m._logger.VERBOSE, frame_print)
end if
' Close
if opcode = m.OPCODE.CLOSE
m._close()
return
' Ping
else if opcode = m.OPCODE.PING
m.send("", m.OPCODE.PONG)
return
' Text
else if opcode = m.OPCODE.TEXT
m._post_message("on_message", {
type: 0,
message: payload.toAsciiString()
})
return
' Data
else if opcode = m.OPCODE.BINARY
payload_array = []
for each byte in payload
payload_array.push(byte)
end for
m._post_message("on_message", {
type: 1,
message: payload_array
})
return
end if
end sub
' Generate a 16 character [A-Za-z0-9] random string and base64 encode it
' @link https://tools.ietf.org/html/rfc6455#section-4.1
' @param self WebSocketClient
' @return string random 16 character base64 encoded string
ws._generate_sec_ws_key = function() as string
sec_ws_key = ""
for char_index = 0 to 15
char = m._CHARS[rnd(m._CHARS.count()) - 1]
if rnd(2) = 1
char = ucase(char)
end if
sec_ws_key += char
end for
ba = createObject("roByteArray")
ba.fromAsciiString(sec_ws_key)
return ba.toBase64String()
end function
' Connect to the specified URL
' @param self WebSocketClient
' @param url_string web socket url to connect
' Format: ws://example.org:80/
' If the port is not specified the port will be assumed
' from the protocol (ws: 80, wss: 443).
ws.open = sub(url as string)
if m._ready_state <> m.STATE.CLOSED
m._logger.printl(m._logger.DEBUG, "WebSocketClient: Tried to open a web socket that was already open")
return
end if
if m._REGEX_URL.isMatch(url)
match = m._REGEX_URL.match(url)
ws_type = lcase(match[1])
host = lcase(match[2])
port = match[3]
path = match[4]
m._hostname = host
' Port
if port <> ""
port = val(port, 10)
else if ws_type = "wss"
m._close()
m._error(1, "wss:// is not supported (Roku cannot do socket TLS); use ws://")
return
else if ws_type = "ws"
port = 80
else
m._close()
m._error(0, "Invalid web socket type specified: " + ws_type)
return
end if
' Path
if path = ""
path = "/"
end if
' Construct handshake
m._sec_ws_key = m._generate_sec_ws_key()
protocols = ""
for each proto in m._protocols
protocols += proto + ", "
end for
if protocols <> ""
protocols = protocols.left(len(protocols) - 2)
end if
handshake = "GET " + path + " HTTP/1.1" + m._NL
handshake += "Host: " + host + ":" + port.toStr() + m._NL
handshake += "Upgrade: websocket" + m._NL
handshake += "Connection: Upgrade" + m._NL
handshake += "Sec-WebSocket-Key: " + m._sec_ws_key + m._NL
if protocols <> ""
handshake += "Sec-WebSocket-Protocol: " + protocols + m._NL
end if
' handshake += "Sec-WebSocket-Extensions: " + m._NL
handshake += "Sec-WebSocket-Version: 13" + m._NL
handshake += m._get_parsed_user_headers()
handshake += m._NL
m._handshake = handshake
' Create socket
m._state(m.STATE.CONNECTING)
address = createObject("roSocketAddress")
address.setHostName(host)
address.setPort(port)
if not address.isAddressValid()
m._close()
m._error(2, "Invalid hostname")
return
end if
m._data_size = 0
m._socket = createObject("roStreamSocket")
m._socket.notifyReadable(true)
m._socket.notifyWritable(true)
m._socket.notifyException(true)
' set up segmentation on 576 which is lowest value of MTU speed up
' transmition around 15%-22%, tested on 4660X - Roku Ultra and
' 3500EU - Roku Stick
m._socket.setMaxSeg(576)
m._socket.setMessagePort(m._ws_port)
m._socket.setSendToAddress(address)
m._sent_handshake = false
m._has_received_handshake = false
if not m._socket.connect()
m._close()
m._error(3, "Socket failed to connect: " + m._socket.status().toStr())
return
end if
else
m._close()
m._error(1, "Invalid URL specified")
end if
end sub
' Parse header array and return a string of headers delimited by CRLF
' @param self WebSocketClient
ws._get_parsed_user_headers = function() as string
if m._headers = invalid or m._headers.count() = 0 or (m._headers.count() mod 2) = 1
return ""
end if
header_string = ""
for header_index = 0 to m._headers.count() - 1 step 2
header = m._headers[header_index]
value = m._headers[header_index + 1]
header_string += header + ": " + value + m._NL
end for
return header_string
end function
' Set ready state
' @param self WebSocketClient
' @param state
ws._state = sub(_state as integer)
m._ready_state = _state
m._post_message("ready_state", _state)
end sub
' Send an error event
' Posts an on_error message to the message port
' @param self WebSocketClient
' @param code integer error code
' @param message string error message
ws._error = sub(code as integer, message as string)
m._logger.printl(m._logger.EXTRA, "WebSocketClient: Error: " + message)
m._post_message("on_error", {
code: code,
message: message
})
end sub
' Close the socket
' @param WebSocketClient
' @param code integer - status code
' @param reason roByteArray - reason
ws._close = sub(code = 1000 as integer, reason = invalid as object)
if m._socket <> invalid
' Send the closing frame
if m._ready_state = m.STATE.OPEN
m._send_close_frame(code, reason)
m._started_closing = uptime(0)
m._state(m.STATE.CLOSING)
else
m._state(m.STATE.CLOSED)
m._post_message("on_close", "")
m._socket.close()
end if
else if m._ready_state <> m.STATE.CLOSED
m._state(m.STATE.CLOSED)
end if
end sub
' Send a close frame to the server to initiate a close
' @param self WebSocketClient
' @param code integer - status code
' @param reason roByteArray - reason
ws._send_close_frame = sub(code as integer, reason as dynamic)
message = createObject("roByteArray")
message.push(code >> 8)
message.push(code)
if reason <> invalid
message.append(reason)
end if
m.send(message, m.OPCODE.CLOSE, true, false)
end sub
' Close the socket
' @self WebSocketClient
' @param reason array - array [code as integer, message as roString]
ws.close = sub(params as object)
code = 1000
reason = createObject("roByteArray")
if params.count() > 0
code = params[0]
if type(code) <> "Integer" or code > &hffff
m._logger.printl(m._logger.DEBUG, "WebSocketClient: close expects value at array index 0 to be a 16-bit integer")
end if
end if
if params.count() > 1
message = params[1]
if type(message) <> "roString" or type(message) <> "String"
reason.fromAsciiString(message)
else
m._logger.printl(m._logger.DEBUG, "WebSocketClient: close expects value at array index 1 to be a string")
end if
end if
m._close(code, reason)
end sub
' Return constructed instance
return ws
end function