Files

@m1k1o/neko

Framework-free client library for the neko v3 API: REST, the /api/ws event protocol, the stream through a pluggable transport (WebRTC with its binary input channel, see Transports), and a pluggable control device, with the keyboard/mouse/touch overlay (wheel, file drop, the host's cursor) included (see Control and input). It is what the React GUI in ../src is built on, and it can drive a neko server from any framework or from a plain page.

ESM, TypeScript declarations included; the one runtime dependency is zustand (its vanilla store, ~1 KB), which holds the state.

npm install @m1k1o/neko

Quick start

import { NekoClient, mount } from '@m1k1o/neko'

const client = new NekoClient({ autologin: true, autoconnect: true, autoplay: true })

// state: connection, video, control, screen, sessions, settings (a zustand store: select what you need)
client.store.subscribe(
  (s) => s.connection.status,
  (status) => render(client.state),
)
// one-off happenings, including plugin events (chat/*, filetransfer/*, ...)
client.events.on('message', (event, payload) => console.log(event, payload))

// point at a server: resumes a saved session (autologin) or logs in with ?token= in the URL
client.setUrl('https://neko.example.com/')
// or log in by hand
await client.login('name', 'password')
client.connect()

// the video, with the keyboard/mouse overlay on top (mount() does the two attaches below)
const unmount = mount(client, document.getElementById('video')!)

The two pieces separately — the stream alone, or with the overlay as a second step:

import { Overlay } from '@m1k1o/neko'

const box = document.getElementById('box')! // position: relative, inside the area the video fills
const detach = client.transport.attach(box) // the stream: a <video> that fills the box
const overlay = new Overlay(client)
const detachOverlay = overlay.attach(box) // keyboard, mouse, touch and the host's cursor over it

NekoClient

Options

option default
autologin false remember the session token in localStorage (neko_session) and resume it
autoconnect false connect as soon as setUrl() finds a valid session
autoplay false start playback when the stream becomes playable (read each time, so a getter works)
inputMode 'auto' 'touch' or 'mouse' instead of detecting it (isTouchDevice)
transport WebRTC a StreamTransport (or a factory for one), see Transports

Authentication

method
setUrl(url = location.href) the server (defaults to the page itself); ?token= logs in with that token; with autoconnect connects
authenticate(token?) validate a token (or the saved one with autologin) with GET /api/whoami
login(username, password) POST /api/login; keeps the token when the server runs without cookies
logout() POST /api/logout, forgets the token

Connection

connect() opens the websocket (/api/ws) and, once the server has introduced the session (system/init), starts the transport, which brings up the stream (WebRTC: asks for an offer and answers it; the server's ICE candidates are buffered until the offer is applied). The connection is kept alive by itself: a socket that goes silent is replaced and the transport started over on the new one, a peer the server drops is requested again, and after repeated failures the client backs off and finally gives up (connection.closed with an error). While the socket is away the stream is left alone, so the last picture stays (and keeps streaming while it can) until the reconnect replaces it. disconnect() closes everything, the stream included (the element goes black); the session stays valid.

connection.status follows both: 'connecting' while the socket is being (re)opened or the transport has no stream yet, 'connected' once the transport reports 'connected' (or 'unavailable': the server will not stream to this session, the room is shown without video).

event / state
state.connection.status 'disconnected', 'connecting', 'connected'
events: 'connection.status' the status changed
events: 'connection.closed' closed; with an Error when not asked for
connected, session, isAdmin, implicitControl computed from the state (see State)
transport the StreamTransport in use

State

client.store is a zustand vanilla store (createStore with subscribeWithSelector), and client.state is its current state, client.store.getState(): a plain object that is replaced, never mutated, on every change (the changed slice is a new object, the rest keeps its identity). Read it anywhere; subscribe with a selector to be called when that value changes:

const off = client.store.subscribe(
  (s) => s.control.host_id,
  (hostId, before) => {},
) // when the selected value changes (Object.is; options: { equalityFn, fireImmediately })
client.store.subscribe((state, before) => {}) // after every change
useStore(client.store, (s) => s.video.playing) // React: zustand's hook, re-renders on that value only

selectSession, selectControlling and selectIsAdmin compute what the getters session, controlling and isAdmin report, from a state: client.store.subscribe(selectControlling, (on) => {}), useStore(client.store, selectIsAdmin). The client owns the state: client.store.setState() exists (zustand, and the tests use it) but nothing a consumer writes is sent to the server.

field
authenticated a valid session is known
connection.{url, token, status}
video.{playable, playing, volume, muted} as the transport reports it; plus mutedByAutoplay when the browser refused sound
control.host_id who has control (controlling compares it with session_id)
control.locked local lock: keep control, send no input
control.clipboard the remote clipboard, as the server reports it
control.{scroll, keyboard, touch} input settings and whether the server takes native touch events
screen.{size, configurations} the current and the available screen sizes
session_id, sessions this session and all sessions (Session: id, profile, state)
settings room settings (Settings: locks, implicit hosting, plugins, ...)
mobile_keyboard_open the on-screen keyboard is up (touch devices)

Events

client.events is a typed Emitter (on, off, once; on returns the unsubscribe):

event arguments
session.created id
session.updated id (profile or state changed)
session.deleted id
room.control.host hasHost, hostId, by
room.control.request id of the member asking for control (hosts only)
room.screen.updated width, height, rate, by
room.settings.updated settings, by
room.clipboard.updated text
room.broadcast.status active, url
receive.broadcast sender, subject, body (sendBroadcast from another client)
receive.unicast sender, subject, body
upload.drop.progress { loaded, total } of a file dropped on the video
upload.drop.finished error?
overlay.click a click on the video (the GUI hints at the control button with it)
message event, payload: everything else, i.e. plugin events (chat/message)

Video and audio

The stream is the transport's: transport.attach(box) creates its media element inside box (a <video> filling it, keeping the aspect ratio by itself) and returns what removes it again; the media calls below go through the transport. mount(client, el) (a plain function, not a method) attaches the transport and an Overlay into a box it letterboxes inside el, and returns the function that takes them out again.

method / field
transport.attach(box) the media element into box; returns the detach function
transport.element the transport's media element (a <video> for WebRTC), once attached
play(), pause() when the browser refuses sound, play() starts muted (mutedByAutoplay) and unmutes on the first click
mute(), unmute(), setVolume(v) v in 0..1
shareMedia(stream) send local media (microphone) on a transport that can; returns the function that stops sharing it

Control and input

Input reaches the remote desktop through client.input, an InputChannel in the server's terms: the transport's own channel when it has one (WebRTC: the data channel, DataChannelInput, binary frames), otherwise WebSocketInput, which sends the same input as the server's control/* websocket events. The client does not care what produces the input: a control device is anything that calls these methods, and the one included is the Overlay.

interface InputChannel {
  move(x: number, y: number): void // remote screen coordinates
  scroll(deltaX: number, deltaY: number, controlKey: boolean): void
  button(code: number, down: boolean): void // X11 button code
  key(keysym: number, down: boolean): void // X11 keysym
  touch(phase: 'begin' | 'update' | 'end', id: number, x: number, y: number, pressure: number): void
}

Overlay is the keyboard/mouse/touch device: a transparent layer over the stream that turns DOM mouse, wheel, keyboard (Apache Guacamole's keyboard, vendored in keyboard/, so keysyms match the server's layouts), touch, drop and paste events into client.input calls while this member is host and not locked, asks for control on a click under implicit hosting, and draws the host's cursor for everyone else from the transport's cursor.* events. new Overlay(client) builds it (it reads the client's state and talks to it); overlay.attach(box) puts it into the box that already holds the transport's element (so the stream is below) and keeps that box letterboxed inside its parent to the remote screen's aspect ratio, so that its canvas maps onto the picture; the returned function detaches it (a second attach replaces the first). overlay.mobileKeyboardToggle() opens / closes the on-screen keyboard on touch devices. A stream without input is just transport.attach(box): the client never loads the overlay or the keyboard unless they are imported.

Another device (a gamepad, a VR controller, a test driver) does what the overlay does: watch client.controlling / client.state.control.locked to know when input is accepted, call client.request() to become host, and send through client.input:

const off = client.store.subscribe(
  (s) => selectControlling(s) && !s.control.locked,
  (on) => (on ? gamepad.start() : gamepad.stop()),
)
gamepad.onStick = (x, y) => client.input.move(x, y) // in remote screen pixels
gamepad.onButton = (n, down) => client.input.button(n + 1, down)
method
request(), release() ask for / give up control
lock(), unlock() local lock (control.locked)
setScrollInverse(v), setScrollSensitivity(n) wheel direction and v3 scroll steps (-5..5)
setKeyboard(layout, variant?) the remote keyboard layout, sent while in control
setScreenSize(width, height, rate) admins: change the screen
paste(text) type text remotely through the server's clipboard
preparePaste(text) before a paste keystroke: the side that copied most recently wins (see the source comment)
uploadDrop({ x, y, files }) drop files into the remote desktop at a position (the overlay does this on drop)
input.move(x, y), input.key(keysym, down), … input straight to the remote desktop (the InputChannel above)

Messages

method
send(event, payload?) a websocket message, e.g. send('chat/message', { text })
sendBroadcast(subject, body) to every other client (receive.broadcast there)
sendUnicast(receiver, subject, body) to one session

REST

client.api (NekoApi) carries the server URL and the token:

const sessions = await client.api.req<Session[]>('GET', '/sessions')
await client.api.req('POST', `/members/${encodeURIComponent(id)}`, { can_login: false })
const blob = await client.api.blob('/room/screen/shot.jpg') // admins
await client.api.upload('/filetransfer', formData, ({ loaded, total }) => {})

Failures throw ApiError with status and the server's message. Session ids can come from login names on some member providers, so they go through encodeURIComponent in paths.

Transports

NekoClient owns the session (auth, the websocket, the room state) and leaves the stream to a StreamTransport (transport.ts), the way Guacamole's client runs over a tunnel it does not care about. WebRTC is the one implemented (WebRTCTransport, transport/webrtc.ts); others (WebCodecs over a websocket, WebTransport, MSE, HLS) plug in the same way:

interface StreamTransport {
  readonly kind: 'webrtc' | 'webcodecs-ws' | 'ws-mse' | 'hls'
  readonly element: HTMLElement | null // its media element, created by attach()
  readonly input?: InputChannel // its own input path, if any (the data channel)

  connect(session: SessionInfo): Promise<void> // start streaming; again on every reconnect, replacing what runs
  close(): void // the stream is over: peer, timers, counters and the picture on the element go

  attach(container: HTMLElement): () => void // create the media element inside; returns what removes it

  setPlaying(on: boolean): Promise<void>
  setVolume(volume: number): void
  setMuted(on: boolean): void
  shareMedia?(stream: MediaStream): () => void // local media (microphone); returns what stops sharing it

  on(event: 'state' | 'stats' | 'error' | 'cursor.position' | 'cursor.image', cb): () => void
}

SessionInfo is what the client hands over on system/init: the server url and token, the init payload, and send(event, payload) / on(event, cb) for the transport's own messages over the main socket (every message reaches these subscribers; signal/* mean nothing to the client itself, which passes them on as message events like any event it does not handle). A socket loss does not touch the transport: send drops until the socket is back, and the reconnect's system/init calls connect() again, which replaces whatever the previous session left (its peer, retries and failure count) while the last picture stays until the new stream shows; close() is called only when the connection ends for good (disconnect(), a kick, giving up) and empties the element.

A transport reports 'state' ({ status, size, video }: its status — 'connecting', 'connected', 'unavailable' when the server will not stream, 'disconnected' — the stream's size and the media element's playable/playing/volume/muted/mutedByAutoplay, mirrored into state.video; its status counts only while the socket is up), 'error' (the stream is lost for good; the client closes the connection with it), the host's 'cursor.position'/'cursor.image' when its server feeds them back (the Overlay draws them), and 'stats' (reserved). A transport does not start playback by itself: the client calls setPlaying(true) when playable turns true and autoplay is on, and a transport keeps playing across its own reconnects. A second transport needs: its own connection from the SessionInfo, an element it draws into, play/volume/mute, and either an input channel or nothing (the client then sends input over the websocket). Pass it as new NekoClient({ transport }); the GUI checks WebRTCTransport.supported() before rendering.

The store and Emitter

The state is a zustand store (createStore from zustand/vanilla with subscribeWithSelector from zustand/middleware; zustand is the package's one runtime dependency): every change is one immutable setState, listeners run synchronously in it, and a selector subscription fires only when its value changes (see State). A GUI makes its own stores the same way and selects from all of them with one hook (useStore from zustand). Emitter<Events> is a small typed event emitter (on returns the unsubscribe, once, off, emit), exported for the same reason.

Types

The wire types mirror server/pkg/types: Session, MemberProfile, SessionState, Settings, ScreenSize, InitPayload, LoginResponse, MemberData, CursorImage, the client State and the NekoEvents map; the transport seam is StreamTransport, SessionInfo, InputChannel, TransportKind, TransportStatus, TransportState and TransportEvents.

Development

npm run build   # tsc -> dist/ (ESM + .d.ts), plus the vendored keyboard library
npm test        # the unit tests in src/**/*.test.ts (vitest, run from ../ where it is installed): the connection
                # state machine (handshake, reconnects, timeouts, events, clipboard, auth), the transport seam,
                # the WebRTC transport, the input channels, the overlay and the store, against the fake browser
                # in src/test/browser.ts (sockets, peers, elements, fake timers)
npm run check   # build, the tests, then node dist.check.mjs against the built package: exports, the store
                # (selector subscriptions, immutable updates, zustand resolvable), Emitter, setUrl, .d.ts
                # specifiers, and that client.js never imports the overlay or the keyboard

The tests and dist.check.mjs run under Node with a few browser globals stubbed, so the connection state machine and the store are tested without a browser. The GUI in ../src consumes this package from source through the @m1k1o/neko alias in ../tsconfig.json and ../vite.config.ts; consumers of the npm package get dist/.