Skip to content

Transports

A transport is an ordinary controller plugin that exposes the command bus and event bus over some wire protocol. Launchpad ships two:

  • IPC transport — a Unix socket (named pipe on Windows), gated by filesystem permissions. Used by the CLI to talk to a running launchpad start daemon.
  • HTTP/SSE transport (httpTransport) — a small HTTP surface on loopback, for consumers that can't open a Unix socket: browsers and Unity/.NET clients.

This page covers the HTTP/SSE transport.

Adding the transport

httpTransport is a plugin like any other; add it to the plugins array alongside the plugins whose commands and events you want to expose:

typescript
import { content } from "@bluecadet/launchpad/content";
import { httpTransport } from "@bluecadet/launchpad/controller/transports/http";

export default defineConfig({
  plugins: [content({ versioning: true }), httpTransport({ port: 8710 })],
});

NOTE

Push over /events is best-effort sugar on top of the version manifest poll contract. Applications must keep polling <downloadPath>/manifest.json — the HTTP transport is a lower-latency notification, not a replacement.

In task mode (one-shot CLI invocations) the plugin is inert and never binds a port. Options are still validated in task mode, so a malformed config (e.g. an out-of-range port) fails setup even though nothing is bound.

Options

OptionTypeDefaultDescription
portnumber8710Port to listen on. 0 picks a random free port (useful in tests).
hoststring"127.0.0.1"Host/interface to bind.
allowedCommandsstring[]["content.ack", "content.manifest.read"]Command types accepted by POST /command. Anything else is rejected with 403.
eventsstring[]["content:*"]Event names forwarded to SSE clients. An entry ending in * prefix-matches everything before it; the single entry * matches all events; any other entry is an exact match.
replayEventsstring[]["content:version:promoted"]Event names eligible for replay-on-connect. For each listed name, the transport remembers that event's last emitted frame and replays it to newly-connected clients; an event must also pass the events filter to be replayed.
keepAliveMsnumber15000Interval between : ping SSE comment lines, keeping idle connections (and intermediate proxies) alive.
maxClientsnumber32Maximum concurrent SSE clients. Further GET /events requests get 503 once this is reached.
exposeStatebooleanfalseExpose the full global state at GET /state. Off by default — see Security model.

Endpoints

All responses carry Access-Control-Allow-Origin: *.

GET /events

Opens a Server-Sent Events stream. On connect, the transport writes:

  1. A retry: 2000 line, telling EventSource to wait 2 seconds before reconnecting after a drop.
  2. The replay backlog (see below).

After that, every bus event matching the events option is forwarded as an SSE frame, and a : ping comment line is written every keepAliveMs.

If maxClients concurrent streams are already open, the request gets 503 instead of a stream.

Replay on connect

A client that connects between two emissions would otherwise learn nothing until the next one. To close that gap, the transport remembers the last frame of each event name listed in replayEvents and replays those frames — in the order each event was last emitted — to every newly connected client, before live streaming begins. Events not in replayEvents stream live only; they're never replayed, no matter how the events filter is configured.

Consequences worth knowing:

  • Only replayEvents names are remembered. By default that's just content:version:promoted; other events (e.g. content:fetch:start) still pass through GET /events live if events allows them, but connecting late means missing them.
  • One frame per listed event name. A second emission replaces the remembered frame; clients never receive a backlog of two frames for one event.
  • To be remembered, an event must also pass the events filter — listing a name in replayEvents doesn't override events.
  • The backlog lives in memory and starts empty. A client connecting to a controller that hasn't emitted a replayable event yet gets only the retry: line for that event.

In practice this is what makes a fresh browser or Unity client learn the active content version without waiting for the next fetch: the content plugin announces the active version on disk during the ready() phase, shortly after the port opens during setup(). A client that connects in that narrow window receives the announcement live rather than from the backlog; one that connects afterward gets it replayed.

event: content:version:promoted
data: {"versionId":"20260714T153045Z","versionPath":"versions/20260714T153045Z","generatedAt":"2026-07-14T15:30:47.112Z"}

POST /command

Dispatches a command from allowedCommands. Body is JSON with at least a type field, capped at 64KB.

StatusWhen
200Command dispatched; body is { result }.
400Body isn't valid JSON, or has no string type field, or the request body couldn't be read.
403type isn't in allowedCommands.
413Body exceeds the 64KB limit.
500The command dispatched but returned an error; body is { error: { name, message, cause? } }, with cause recursing through the error chain.
bash
curl -X POST http://127.0.0.1:8710/command \
  -H 'Content-Type: application/json' \
  -d '{"type":"content.manifest.read"}'

GET /status

Returns the same display-oriented status snapshot as launchpad status, as JSON.

GET /state

Returns the full global controller state as JSON. Returns 404 unless exposeState: true — see Security model.

OPTIONS *

Answers any path with a 204 CORS preflight response (GET, POST, OPTIONS, Content-Type header allowed, cached 24h).

Anything else

404, with { error: { message } } naming the method and path.

SSE wire format

retry: 2000

event: content:version:promoted
data: {"versionId":"20260714T153045Z","versionPath":"versions/20260714T153045Z","generatedAt":"2026-07-14T15:30:47.112Z"}

: ping

Multi-line data is framed with one data: field per line, so a payload with embedded newlines survives EventSource reassembly.

Security model

Loopback HTTP is not equivalent to the IPC transport's Unix socket. A Unix socket's reachability is gated by filesystem permissions on the socket path. A loopback TCP port is reachable by any process on the machine, and — because browsers allow no-cors cross-origin requests to complete even though the response body is opaque to the page — by drive-by JavaScript running in any tab the user has open, regardless of CORS headers. POST /command in particular can be triggered blind from a malicious page.

This is why the transport is deliberately conservative:

  • Command allowlist. Only allowedCommands can be dispatched; everything else is 403. There is no way to widen this from the wire — only from config.
  • No shutdown route. Unlike the IPC transport, there is no way to stop the controller over HTTP.
  • /state is opt-in. Full global state can contain more than a browser page should be able to read passively; it is 404 unless exposeState: true.

Treat port/host and allowedCommands as the trust boundary. Don't allowlist a command with side effects you wouldn't want any local process to trigger.

Limitations

  • Task mode is a no-op. One-shot CLI runs never bind a port; the transport only runs alongside a persistent controller.
  • JSON serialization is lossy. Event payloads and command results are serialized with JSON.stringify-equivalent semantics, not the devalue codec the IPC transport uses. Cycles, Map/Set, undefined, and other non-JSON values won't round-trip.
  • Slow SSE clients may drop events. Writes are fire-and-forget with no backpressure handling; a client that can't keep up may silently miss events. The manifest poll fallback covers this by design — see Version Manifest.
  • A failed port bind is a hard setup failure. EADDRINUSE and similar bind errors fail plugin setup with no auto-recovery.

Released under the ISC License.