components_vendor_BrightWebSocket_web_socket_client_WebSocketClientTask.brs
' WebSocketClientTask.brs
' Copyright (C) 2018 Rolando Islas
' Released under the MIT license
'
' BrightScript, SceneGraph Task wrapper for the web socket client
'
' JellyRock modification (see ../../README.md): removed the `secure`/TLS field
' handling (ws:// only — upstream TLS was unimplemented and Roku can't do socket TLS).
' Entry point
sub init()
' Task init
m.top.functionName = "runSocketLoop"
m.top.control = "RUN"
end sub
' Main task loop.
' JellyRock modification: renamed from `run` — a top-level `run()` collides with the built-in
' global `Run()` (BSC: cannot-use-reserved-word / native-function-collision). It worked upstream
' only because the Task dispatches it by the `functionName` STRING, never a scope call; renamed so
' we don't rely on that and the diagnostic is genuinely resolved, not just suppressed.
sub runSocketLoop()
m.ws = WebSocketClient()
m.port = createObject("roMessagePort")
m.ws.set_message_port(m.port)
' Fields
m.top.STATE_CONNECTING = m.ws.STATE.CONNECTING
m.top.STATE_OPEN = m.ws.STATE.OPEN
m.top.STATE_CLOSING = m.ws.STATE.CLOSING
m.top.STATE_CLOSED = m.ws.STATE.CLOSED
m.top.ready_state = m.ws.get_ready_state()
m.top.protocols = m.ws.get_protocols()
m.top.buffer_size = m.ws.get_buffer_size()
' Event listeners
m.top.observeField("open", m.port)
m.top.observeField("send", m.port)
m.top.observeField("close", m.port)
m.top.observeField("buffer_size", m.port)
m.top.observeField("protocols", m.port)
m.top.observeField("headers", m.port)
m.top.observeField("log_level", m.port)
' JellyRock modification: seed the client FROM the node instead of overwriting the node with the
' client's empty defaults (upstream did `m.top.headers = m.ws.get_headers()` above). init() sets
' control="RUN", so a caller that sets `headers` right after CreateObject races this thread's
' startup and upstream silently discarded the value. Seeding AFTER the observers are registered
' closes the window in both directions: a write that landed earlier is picked up here, a later one
' arrives as a field event. Load-bearing — the remote-control receiver sets the Authorization
' header that binds the socket's Jellyfin DeviceId, and losing it re-splits the session (#743).
if m.top.headers <> invalid and m.top.headers.count() > 0
m.ws.set_headers(m.top.headers)
end if
' JellyRock modification: ready_state is CLOSED before open() is ever called, so the loop-exit
' check below must only fire once a connection was actually requested.
m.connect_requested = false
' Set at the END of the iteration in which a requested connection reached CLOSED. The exit test
' at the head of the loop then waits for the NEXT drained wait() before releasing the thread.
m.connection_closed = false
if len(m.top.open) > 0
m.ws.open(m.top.open)
m.connect_requested = true
end if
while true
' Check task messages. JellyRock hardening: poll every 100ms instead of the upstream
' 1ms busy-loop — this is a latency-tolerant remote-control channel (commands seconds
' apart), so ~100ms adds no perceptible latency but cuts idle CPU ~100x.
msg = wait(100, m.port)
' JellyRock modification: release the Task thread once the connection is over. Upstream looped
' forever, holding one thread per connect attempt against RokuOS's 100-thread cap (the #728
' &h29 crash class). Single-connection contract now: reopening after close needs a fresh node.
'
' This test MUST sit at the HEAD of an iteration, not after m.ws.run() below. run() is what
' performs the CLOSED transition AND posts the final ready_state/on_close/on_error messages to
' m.port (WebSocketClient.brs `_state` / `_close` / `_error`), so a drained-port observation
' taken BEFORE run() says nothing about what run() then enqueued — exiting on it strands those
' events in the queue, m.top.on_close is never written, and RemoteControlTask.connectAndPump
' blocks forever instead of reconnecting. Reaching here with msg = invalid means a wait() taken
' AFTER the CLOSED transition found the port empty, so every terminal event has been forwarded.
' The queue cannot refill afterwards: once CLOSED, _read_socket_data early-returns,
' _try_force_close only fires on CLOSING, and send() bails before posting.
if m.connection_closed and msg = invalid
exit while
end if
' Field event
if type(msg) = "roSGNodeEvent"
if msg.getField() = "open"
m.ws.open(msg.getData())
m.connect_requested = true
else if msg.getField() = "send"
m.ws.send(msg.getData())
else if msg.getField() = "close"
m.ws.close(msg.getData())
else if msg.getField() = "buffer_size"
m.ws.set_buffer_size(msg.getData())
else if msg.getField() = "protocols"
m.ws.set_protocols(msg.getData())
else if msg.getField() = "headers"
m.ws.set_headers(msg.getData())
else if msg.getField() = "log_level"
m.ws.set_log_level(msg.getData())
end if
' WebSocket event
else if type(msg) = "roAssociativeArray"
if msg.id = "on_open"
m.top.on_open = msg.data
else if msg.id = "on_close"
m.top.on_close = msg.data
else if msg.id = "on_message"
m.top.on_message = msg.data
else if msg.id = "on_error"
m.top.on_error = msg.data
else if msg.id = "ready_state"
m.top.ready_state = msg.data
' JellyRock modification: `m.task_port` -> `m.port`. m.task_port is never assigned anywhere in
' this component (3 reads, 0 writes), so these re-observes silently rebound the field to an
' invalid port. Upstream never tripped it because nothing called set_buffer_size/protocols/
' headers; the remote-control receiver now sets `headers`, which makes this path live.
else if msg.id = "buffer_size"
m.top.unobserveField("buffer_size")
m.top.buffer_size = msg.data
m.top.observeField("buffer_size", m.port)
else if msg.id = "protocols"
m.top.unobserveField("protocols")
m.top.protocols = msg.data
m.top.observeField("protocols", m.port)
else if msg.id = "headers"
m.top.unobserveField("headers")
m.top.headers = msg.data
m.top.observeField("headers", m.port)
end if
end if
m.ws.run()
' JellyRock modification: arm the loop-exit test above. Gated on connect_requested because
' ready_state is CLOSED before open() is ever called — an un-opened node must keep waiting for
' its `open` field event rather than releasing the thread immediately.
if m.connect_requested and m.ws.get_ready_state() = m.ws.STATE.CLOSED
m.connection_closed = true
end if
end while
end sub