source_remotecontrol_remoteProtocol.bs
' Transport-agnostic protocol constants + pure helpers for the remote-control receiver: the
' http-gate, the session-socket URL builder, reconnect backoff, and the client KeepAlive frame.
' No socket, no node, no m.global — fully unit-testable.
namespace remoteProtocol
' The http-gate. JellyRock can only open a ws:// socket to an http:// server — Roku can't do wss://
' (no socket TLS), so an https:// session stays uncontrolled (the future plugin long-poll path). Both
' the advertised capability (deviceCapabilities) and the receiver gate on this, so they never disagree.
function isHttpServer(serverUrl as dynamic) as boolean
if not isValidAndNotEmpty(serverUrl) then return false
return LCase(Left(serverUrl, 7)) = "http://"
end function
' Build the Jellyfin session-socket URL from the server URL + session credentials.
' Returns "" when the server isn't http:// (never downgrades an https:// token onto ws://)
' or when a credential is missing. The deviceId MUST match the DeviceId in baseRequest's
' auth header so the socket binds the SAME session the REST API uses.
' http://host:port -> ws://host:port/socket?api_key=<token>&deviceId=<id>
function buildSocketUrl(serverUrl as dynamic, authToken as dynamic, deviceId as dynamic) as string
if not isHttpServer(serverUrl) then return ""
if not isValidAndNotEmpty(authToken) or not isValidAndNotEmpty(deviceId) then return ""
hostPort = Mid(serverUrl, 8) ' strip the leading "http://"
return "ws://" + hostPort + "/socket?api_key=" + authToken + "&deviceId=" + deviceId
end function
' --- HTTPS long-poll transport (#667) ---
' The plugin route prefix. JellyRock consumes the long-poll over TLS (roUrlTransfer) because Roku
' can't wss://. Unlike buildSocketUrl, these carry NO token in the URL — the caller attaches the
' standard Authorization header (baseRequest.buildAuthHeader), which already binds the session by
' DeviceId. See docs/architecture/remote-control-longpoll-contract.md.
const PLUGIN_ROUTE = "/JellyRock/RemoteControl"
' The long-poll wire-contract version this client implements. The plugin advertises its own
' contractVersion in the /info probe body; JellyRock refuses a mismatch and stays dark rather than
' risk acting on a command shape it might misread. See remote-control-longpoll-contract.md (Versioning).
const CONTRACT_VERSION = 1
' Build the plugin's long-poll command-channel URL for a server base URL. Returns "" for a blank
' server URL. waitMs is JellyRock's requested hold ceiling; the client-side transfer timeout is set
' longer so a 204 (empty hold) always arrives before the transfer itself times out.
' https://host -> https://host/JellyRock/RemoteControl/poll?waitMs=<n>
function buildLongPollUrl(serverUrl as dynamic, waitMs as integer) as string
base = pluginBaseUrl(serverUrl)
if base = "" then return ""
return base + "/poll?waitMs=" + waitMs.ToStr()
end function
' Build the plugin presence/version probe URL (200 = plugin present, 404 = absent). Returns "" for a
' blank server URL. No token in the URL (auth is header-based).
' https://host -> https://host/JellyRock/RemoteControl/info
function buildProbeUrl(serverUrl as dynamic) as string
base = pluginBaseUrl(serverUrl)
if base = "" then return ""
return base + "/info"
end function
' Shared plugin route-prefix builder. Trims a single trailing slash on the server URL so we never
' emit "//". Returns "" for a missing/blank server URL.
function pluginBaseUrl(serverUrl as dynamic) as string
if not isValidAndNotEmpty(serverUrl) then return ""
base = serverUrl
if Right(base, 1) = "/" then base = Left(base, Len(base) - 1)
return base + PLUGIN_ROUTE
end function
' Extract the contractVersion from a /info probe body. Returns 0 for a blank/unparseable body, a
' non-object body, a missing key, or a non-numeric value — callers treat anything <> CONTRACT_VERSION
' as "no usable plugin". NEVER throws on a hostile/garbled body (it's network-controlled input): a
' string/object/bool contractVersion is rejected before any coercion that could raise at runtime.
function parseContractVersion(body as dynamic) as integer
if not isValidAndNotEmpty(body) then return 0
parsed = parseJson(body)
if not isValid(parsed) or type(parsed) <> "roAssociativeArray" then return 0
version = parsed.contractVersion
if not isValid(version) then return 0
if getInterface(version, "ifInt") <> invalid or getInterface(version, "ifFloat") <> invalid then return cint(version)
return 0
end function
' Exponential reconnect backoff, 0-based attempt: 1s, 2s, 4s, 8s, 16s, then capped at 30s.
function nextBackoffMs(attempt as integer) as integer
ms = 1000
for i = 1 to attempt
ms = ms * 2
if ms >= 30000 then return 30000
end for
return ms
end function
' The client->server KeepAlive frame. Jellyfin's ForceKeepAlive asks the client to send
' these on the requested interval so the session isn't reaped by the inactivity timeout.
function keepAliveFrame() as string
return FormatJson({ MessageType: "KeepAlive" })
end function
end namespace