trellis
Version:
Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications
123 lines • 6.06 kB
TypeScript
/**
* Trellis Realtime — Relay Hub (server side)
*
* The server counterpart to {@link WebSocketRelayTransport}: a dumb broadcast
* mesh that fans every inbound frame out to all *other* connected peers, with
* optional in-memory replay ({@link RelayPersistence}) so a late joiner catches
* up on presence / chat tail / text state.
*
* This is intentionally NOT {@link RealtimeRoom}. `RealtimeRoom` is a *client*
* abstraction — one peer's view of a room (its own presence + observed peers).
* The relay holds no presence of its own; it only moves bytes between peers and
* remembers enough to replay. Keep the two roles distinct: clients run
* `RealtimeRoom`, the server runs a relay.
*
* Room-scoped: peers are fanned out (and replayed) only to others in the *same*
* room. The room is taken from the upgrade path — `${path}/{room}` — matching
* {@link DurableObjectRelayTransport}'s `${url}/{room}` convention (and the
* reference Durable Object worker, which maps one object per room). A bare
* connection to `path` lands in the `'default'` room. This makes the bundled
* relay a faithful local stand-in for the hosted DO: distinct rooms stay
* isolated, so `joinPresence({ relayUrl })` Just Works against it.
*
* Node-only (uses `node:http` + the optional `ws` package). Exposed through
* `trellis/server`, never `trellis/realtime` (which is browser-bundled).
*
* import { createServer } from 'node:http';
* import { attachRealtimeRelay } from 'trellis/server';
*
* const server = createServer(handler);
* await attachRealtimeRelay(server, { path: '/rt' });
* server.listen(8231);
* // client: joinPresence({ relayUrl: 'ws://localhost:8231/rt', room: 'doc:42' })
* // → connects to ws://localhost:8231/rt/doc:42
*
* @module trellis/server
*/
import type { Server as HttpServer, IncomingMessage } from 'node:http';
import { RelayPersistence } from '../realtime/relay-persistence.js';
import type { BlobStore } from '../vcs/blob-store.js';
import { type BlobRequestHandlerOptions } from './blob-handler.js';
export { createBlobRequestHandler, BLOB_CORS, } from './blob-handler.js';
export type { BlobRequestHandlerOptions } from './blob-handler.js';
/** Mark set on `IncomingMessage` when the blob handler claims the request. */
export declare const TRELLIS_BLOB_CLAIMED: unique symbol;
export declare function isBlobRequestClaimed(req: IncomingMessage): boolean;
export interface RealtimeRelayOptions {
/**
* Base WebSocket upgrade path this relay claims. Default `/rt`. The relay
* accepts `path` (→ room `'default'`) and `${path}/{room}` (→ that room);
* other upgrade paths are left untouched so it can coexist with path-scoped
* WebSocket handlers.
*/
path?: string;
/**
* Per-room replay store factory for late joiners. A fresh store is created
* the first time a room is seen. Pass `false` to disable replay entirely
* (pure fan-out). Default: `() => new RelayPersistence()`.
*/
persistence?: false | (() => RelayPersistence);
/**
* Grace window (ms) before replay is delivered to a new connection when no
* `hello` frame arrives first (UI mounting subscribers can race the socket).
* Default 250.
*/
replayGraceMs?: number;
/** Injectable `ws` WebSocketServer ctor for tests. */
WebSocketServerImpl?: unknown;
/**
* Optional BlobStore factory for content-addressed blob serving
* (`GET`/`HEAD /blob/:sha256`, `PUT /blob`). Pass `false` or omit to leave
* the blob surface off (default). Mirrors {@link persistence} injection.
*/
blobStore?: false | (() => BlobStore);
/** Reject PUT bodies larger than this. Default 64 MiB → 413. */
maxBlobBytes?: number;
/**
* Gate blob writes. Return false → 401. Default: allow all.
* Production embedders should pass a real check.
*/
authorizeBlobWrite?: BlobRequestHandlerOptions['authorizeBlobWrite'];
}
export interface RealtimeRelay {
/** Connected peers — across all rooms, or in `room` when given. */
clientCount(room?: string): number;
/** Currently active room ids (rooms drop when their last peer leaves). */
rooms(): string[];
/** The replay store for `room`, or `null` when replay is disabled. */
persistenceFor(room: string): RelayPersistence | null;
/** Close every peer socket and tear down the WS server. */
close(): Promise<void>;
}
/**
* Mount a realtime relay on an existing HTTP server. Only handles upgrades on
* `opts.path`; other upgrade paths are left untouched, so this can coexist with
* other WebSocket handlers (e.g. the graph-subscription socket) **provided
* those handlers are themselves path-scoped**.
*/
export declare function attachRealtimeRelay(server: HttpServer, opts?: RealtimeRelayOptions): Promise<RealtimeRelay>;
export interface StandaloneRealtimeRelayOptions extends RealtimeRelayOptions {
/** Port to bind. Default 8231. Use 0 for an OS-assigned ephemeral port. */
port?: number;
/** Host to bind. Default `0.0.0.0`. */
hostname?: string;
}
export interface StandaloneRealtimeRelay extends RealtimeRelay {
/** The bound port (resolved, so `port: 0` reports the real ephemeral port). */
port: number;
/** The underlying HTTP server (health check on `/`, 404 otherwise). */
server: HttpServer;
}
/**
* Spin up a standalone relay hub on its own HTTP server. The HTTP surface is
* health (`/` / `/health`) plus optional blob routes when `blobStore` is set;
* the relay lives on the WebSocket upgrade path. For embedding in an existing
* app server, use {@link attachRealtimeRelay} instead.
*
* const relay = await createRealtimeRelay({ port: 8231 });
* // ws://localhost:8231/rt → room 'default'
* // ws://localhost:8231/rt/doc:42 → room 'doc:42' (isolated fan-out)
* await relay.close();
*/
export declare function createRealtimeRelay(opts?: StandaloneRealtimeRelayOptions): Promise<StandaloneRealtimeRelay>;
//# sourceMappingURL=relay-server.d.ts.map