# node-host reference

Accept phones on your own Node 22+ HTTP(S) server. Import `ControllerHost` and `serveControllerAssets` from `@controller-plus/sdk/node-host`. No Controller+ service is required on this route.

## Constructor

`new ControllerHost({ server, origin, game = { id: 'custom-game', name: 'Your game', controls: {} }, path = '/ws', allowedOrigins = [], maxPlayers = 16, now = Date.now } = {})`

| Parameter | Meaning |
| --- | --- |
| `server` | Existing Node HTTP/HTTPS server. The host adds an upgrade handler; it does not call `listen()`. |
| `origin` | Phone-reachable HTTP(S) origin including port. Constructor throws if missing or not HTTP(S). `localhost` on a phone means the phone. |
| `game` | `{ id, name, controls, privateView? }`; used in roster/manifest/default view. |
| `path` | WebSocket path handled by this host. Use a valid join-link path. The generated manifest currently still advertises `/ws`; use the default path for engine compatibility. |
| `allowedOrigins` | Additional exact browser origins; the host's own origin is always allowed. Clients without an Origin header can connect with valid credentials. |
| `maxPlayers` | Maximum allocated seats, including disconnected reservations. Default 16; this adapter does not clamp it. |
| `now` | Millisecond clock function for deadlines; normally leave as `Date.now`. |

One instance owns one room. Properties include `room`, `origin`, `path`, `game`, `maxPlayers`, `expires` (epoch milliseconds, six hours after creation), `players` (Map), `defaultView`, `publicState`, and `server`. Treat tokens, socket collections and mutable player records as host-only data.

## Credentials and presentation

| Full signature | Return and behavior |
| --- | --- |
| `credentials()` | `{ room, hostToken, joinURL, endpoint, manifest }`. Host token grants game authority; show only the player `joinURL` to guests. |
| `qrSVG(): Promise<string>` | Current invitation as 320-pixel SVG, margin 2, error correction M. |
| `qrPNG(): Promise<Buffer>` | Same invitation as PNG bytes. QR library errors reject. |
| `setView(playerId, view): boolean` | `false` if no seat. Choose `viewFor` fallback, JSON-copy and store/send the view; `true` even for a reserved offline seat. Throws if no string `kind` or over 14,000 bytes; serialization errors can also throw. |
| `setDefaultView(view): void` | Clone the new default and apply it to current seats. New seats receive this default. Prefer a gamepad/waiting default and send custom surfaces from `connected`; new-seat defaults are not individually converted through `viewFor`. |
| `publish(state): void` | Clone and broadcast JSON public state, at most 180,000 bytes. Throws for oversize or unserializable data. Do not use `undefined`. |
| `state(): object` | `{ type: 'state', room: { id, name, players: [{ id, name, connected }] }, game: publicState, tier: 'developer' }`. No `v` until transmitted. |
| `broadcast(): void` | Send `state()` to all authenticated sockets. |
| `remove(playerId): void` | Release input, invalidate resume, close/delete seat, rotate the join token and broadcast. Refresh the displayed QR after this call. Unknown IDs do nothing. |
| `close(): Promise<void>` | Release input, terminate sockets, remove the upgrade handler and close WebSocketServer. Does not close your HTTP server. |

Names are stripped of control/format characters and truncated to 24 characters after protocol validation. Seats have stable IDs such as `p1`. A reconnect rotates the resume ticket, resets input/sequence numbers and keeps the same seat. Offline reservations last five minutes.

## Events

`ControllerHost` extends Node `EventEmitter`. `on(event, callback)` returns the host. Use `off(event, callback)` or `once(event, callback)` as normal.

| Event | Exact payload |
| --- | --- |
| `connected` | `{ playerId, name, capabilities, app }`, where `app` is `{ platform, version }` or `null`. Only for a newly allocated seat, **not** resume. |
| `input` | `{ playerId, input }`, full state; also emitted when input is neutralized. Duplicate binary refreshes keep liveness but do not emit this callback. |
| `event` | `{ playerId, event, payload }`. Validate game-specific permissions and data. |
| `disconnected` | `{ playerId }`. Seat remains reserved. |
| `command` | `{ command, payload }` for an authenticated host command not handled by the adapter. |

`connected` fires before the new seat's `socket` and `app` are assigned. `setView` there stores a view for delivery with welcome, but initial fallback selection sees no app. Defer app-specific view selection with `setImmediate`, or send a safe common view first. To observe resume, read roster state from a host Session or check the known seat on subsequent input. There is no `reconnected` event.

## Feedback and phone output

| Signature | Return and behavior |
| --- | --- |
| `registerFeedbackCatalog(entries): void` | Validate against `origin`, remember and send to all phones, including later joins. |
| `playFeedback(playerId, cue, options = {}): void` | Cue options `id?`, `volume = 1`, `pattern?`. |
| `rumble(playerId, options): void` | Required `{ low, high, durationMs }`, optional `id`. No defaults. |
| `stopFeedback(playerId, channel = 'all'): void` | Stop `all`, `sound` or `vibration`. |
| `feedback(playerId, frame): void` | Validate [FeedbackRequest](sdk-feedback.md#feedback-requests) and send to a connected target. Unknown/offline seats are ignored after validation. |
| `output(playerId, type, payload = {}): void` | Only `haptic` or `capability-request`; otherwise throws. Sends to the target if present. This low-level API does not validate the payload or grant permissions. Prefer the bounded feedback methods. |

Feedback validation throws before sending. Feedback is transient and is not replayed after reconnect.

## Browser assets

`await serveControllerAssets(req, res): Promise<boolean>` serves `/join` and `/sdk/` for GET/HEAD. Returns `true` if it handled the request (including a 400/404/405 response), `false` for other paths. Mount before your game router. It serves only SDK `web/`, `src/`, `assets/`, `vendor/`, and `examples/games/` paths; it rejects hidden/traversal paths. It does not serve your game or sound catalog assets.

## Low-level adapter hooks

These public methods support the adapter's implementation. Most games never call them. They use `ws` WebSocket instances and internal player records, not browser `Session` objects.

| Signature | Behavior |
| --- | --- |
| `accept(ws): void` | Install handshake, input and close handlers on a new socket. |
| `binary(ws, raw): void` | Process negotiated wire input/pings; invalid frames can throw a WireError. |
| `hostFrame(frame): void` | Send a JSON frame to authenticated host sockets. |
| `error(ws, code, message, close = false): void` | Send error frame; optionally close with 4003. |
| `release(player): void` | Replace input with neutral and notify local and socket hosts. |
| `sweep(): void` | Enforce liveness, reservations and expiry; automatic every 250 ms. |

## Limits and failures

Client messages: 16,384 bytes and 180 messages/second per socket. Authenticate within five seconds. A send buffer over 262,144 bytes closes as 4008. Held input releases after one second without input, sampled every 250 ms. Player application-message silence beyond five seconds terminates the connection; socket traffic silence beyond eight seconds also terminates it. Session pings keep quiet controllers alive.

JSON errors include the [protocol error codes](sdk-protocol.md#errors). Adapter errors include `room_full`, `room_expired`, `unauthorized`, `invalid_resume`, `forbidden`, `rate_limited`, `invalid_binary` and `handler_error`. A game callback exception during message handling becomes a generic `handler_error` on the phone. Do not throw from normal event handling; validate and ignore disallowed actions.

## Complete server

<!-- example: node-host -->
```js
import http from 'node:http';
import { ControllerHost, serveControllerAssets } from '@controller-plus/sdk/node-host';

const port = 47411;
export const server = http.createServer(async (req, res) => {
  if (await serveControllerAssets(req, res)) return;
  if (req.url === '/qr.svg') {
    res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
    return res.end(await host.qrSVG());
  }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end('<h1>Join the game</h1><img src="/qr.svg" alt="Scan to join">');
});
export const host = new ControllerHost({
  server, origin: process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`,
  game: { id: 'direct-example', name: 'Direct game', controls: {} }, maxPlayers: 8,
});
host.on('connected', ({ playerId }) => {
  setImmediate(() => host.setView(playerId, { kind: 'waiting', title: 'Lobby', text: 'You joined' }));
});
host.on('event', ({ playerId, event }) => {
  if (event === 'ready') host.playFeedback(playerId, 'ui.confirm');
});
await new Promise(resolve => server.listen(port, '0.0.0.0', resolve));
export async function stop() { await host.close(); await new Promise(r => server.close(r)); }
process.once('SIGINT', stop);
```

For another device, set `PUBLIC_ORIGIN` to this computer's reachable LAN address with port 47411 before starting. Use HTTPS/WSS on the internet. See [Quickstart](quickstart.md) for game state, player names, reconnects and QR refresh after removal.
