Transports
A transport is the wire protocol Netron speaks. Four are built in;
each is opt-in. The same @Service class is reachable over any
transport you bind, with the same calling convention and the same
middleware stack.
The four transports
| Transport | Best for | Latency | Throughput | Streaming |
| HTTP | Browsers, REST integrations, reverse-proxy environments | Med | Low–med | No |
| WebSocket | Browser subscriptions, persistent client sessions | Low | High | Yes |
| TCP | Service-to-service inside a cluster | Lowest | Highest | Yes |
| Unix | Sidecars, CLI ↔ daemon, low-overhead local IPC | Lowest | Highest | Yes |
Configuring transports
Register transports on the application's Netron instance before
startup. registerTransportServer stores a server configuration;
app.start() starts the registered listeners:
import { HttpTransport } from '@omnitron-dev/titan/netron/transport/http';
// WebSocket / TCP / Unix transports auto-register in Node; HTTP must be
// registered explicitly.
const app = await Application.create(AppModule);
const netron = app.netron!;
netron.registerTransport('http', () => new HttpTransport());
netron.registerTransportServer('http', { name: 'api', options: { port: 3000 } });
netron.registerTransportServer('websocket', { name: 'ws', options: { port: 3001 } });
netron.registerTransportServer('tcp', { name: 'tcp', options: { port: 4001 } });
netron.registerTransportServer('unix', { name: 'sock', options: { path: '/tmp/myapp.sock' } });
await app.start();registerTransportServer(name, { name, options }) takes a transport
key, a server label, and an options bag ({ host?, port?, path?, … }).
Only the transports you start are bound. An unrestricted published service
is reachable over each bound transport. Restrict its remote surface at the
service or member declaration:
@Service({ name: 'orders', transports: [new HttpTransport()] })
class OrdersService {
@Public({ transports: ['http'], auth: { roles: ['operator'] } })
async list() { /* … */ }
}The effective surface is the intersection of service and member restrictions.
Omitting transports adds no restriction; [] denies all remote transports.
ws normalizes to websocket. Custom transports use their implementation's
name, not a listener label. Invalid declarations fail during publication.
Restrictions do not start listeners or replace authentication and permissions.
The server determines the ingress transport. A request header or RPC argument cannot select another transport identity. HTTP calls and batches, persistent method calls and property operations all enforce the same boundary. Discovery, OpenAPI and returned nested service interfaces show the corresponding surface; calling a known service name or definition ID does not bypass it. Trusted in-process calls remain local operations.
If interface discovery is denied by a transport restriction, the client receives the same service-not-found response as for an unknown service. The server logs a warning with the requested service, peer ID and actual ingress transport, so operators can distinguish a denied route from a missing publication without revealing a private service to the caller.
Forwarded definitions carry optional transport restrictions. A gateway cannot widen them; definitions from older publishers without these fields remain unrestricted by transport. Remote forwarding also requires an explicit gateway ACL.
When to choose what
HTTP
Choose HTTP when:
Browsers call your service directly. (Use WebSocket only if you need streaming or long-lived sessions.)
A reverse proxy sits between client and server (NGINX, Cloud Load Balancer, K8s Ingress). HTTP/1.1 traverses these without special config.
You want curl-debuggable endpoints.
Cost: per-call HTTP framing overhead (~50–200 µs). Negligible for human-driven traffic; significant for service-to-service hot paths.
WebSocket
Choose WebSocket when:
You need server-push (subscriptions, long-lived events).
You need persistent sessions (auth setup once per connection, reused across many calls).
You want low per-call overhead from a browser.
Cost: connection state on the server. WebSocket is stateful; a per-pod connection limit applies.
TCP
Choose TCP when:
Server-to-server inside a cluster, behind a private network.
You need the lowest possible latency.
You don't need a reverse proxy (TCP doesn't traverse most HTTP-only proxies).
Cost: one persistent TCP connection per peer pair. RPC calls are multiplexed on that connection; an additional connection pool is not required.
Unix sockets
Choose Unix when:
Two processes on the same host need to communicate (sidecar pattern, CLI ↔ daemon).
You want OS-level access control via filesystem permissions.
You want zero TCP/TLS overhead.
Limitation: same-host only. The Omnitron CLI uses Unix sockets to talk to local supervisors.
Transport options — common
All transports extend a shared TransportOptions base
(netron/transport/types.ts). The most-used fields:
| Option | Default | Effect |
port / path | — | Where to listen (set via the server options) |
host | '0.0.0.0' (TCP/WS), 'localhost' (HTTP) | Interface to bind |
maxPacketSize | 16 * 1024 * 1024 (16 MiB) | Hard ceiling on an inbound packet, checked before decode |
connectTimeout | 10_000 | Transport establishment and peer greeting deadline, on client and server |
requestTimeout | 5_000 | Per-request timeout |
streamTimeout | 30_000 | Idle-stream timeout |
keepAlive | { enabled?, interval?, timeout? } | Keepalive config object |
compression | false | Enable message compression |
Transport-specific options layer on top:
WebSocket —
maxPayload,perMessageDeflate,handshakeTimeout,protocols,pathPrefix.TCP —
noDelay(disable Nagle),keepAliveDelay,allowHalfOpen.HTTP —
cors,maxRequestSize(e.g.'10mb'),keepAliveTimeout,customRoutes,maxAsyncGeneratorItems,pathPrefix.
There is no unified maxPayloadBytes / keepAliveMs /
connectionTimeoutMs option — those names do not exist; use the real
ones above.
Share a request budget across HTTP and WebSocket
Create one limiter per process and pass the same object to the listeners that serve the same clients. A client key has one fixed window across both transports. The limiter also has a fixed window for all clients. Each window starts on its first accepted request and resets after windowMs.
import { TransportRequestLimiter } from '@omnitron-dev/titan/netron';
const requestLimiter = new TransportRequestLimiter({
enabled: true, windowMs: 60_000, maxRequests: 300, globalMaxRequests: 2_000,
});
netron.registerTransportServer('http', {
name: 'api', options: { host: '127.0.0.1', port: 8010, requestLimiter },
});
netron.registerTransportServer('websocket', {
name: 'events', options: { host: '127.0.0.1', port: 8011, requestLimiter },
});HTTP reserves one unit before reading the body. A batch of N calls requires another N−1 units before the first handler runs. If the batch exceeds the remaining allowance, the ingress request still counts and no handler runs. Opening a WebSocket and initiating a call or task each consume one unit. Responses to requests already in flight can complete when the window is full.
The default key is the remote socket address. Enable trustProxy only on a listener reachable exclusively through a trusted proxy that overwrites the forwarding headers on ordinary requests and upgrades. An exhausted HTTP request or upgrade receives 429 and Retry-After in seconds. On an established WebSocket the caller receives a Netron rate-limit error. A stream that cannot deliver its next frame, or a peer that keeps sending while its refusal is still pending, closes with WebSocket code 1013. Handle that close with bounded reconnect backoff. A key on the optional whitelist bypasses both ceilings. Separate daemon processes maintain independent windows. Method authorization still applies after transport admission.
Connection limits and lifecycle
Netron enforces maxTotalConnections (default 100) and
maxConnectionsPerPeer (default 10) through one connection manager. Pending
incoming greetings occupy capacity. Outbound HTTP peer handles count too;
inbound HTTP requests use their transport middleware limits. Configure limits
from measured peak concurrency before upgrading an older build in which these
settings only emitted a warning.
const netron = new Netron(logger, {
maxTotalConnections: 500,
maxConnectionsPerPeer: 10,
});netron.connectionManager.getStats() reports actual connections, per-peer counts
and transport byte totals. Slots are released on disconnect, rejected handshake
and shutdown, including clients that never start a listening server.
A peer ID is a routing identity, not authentication. Incoming greetings cannot
replace an already-connected identity. Use a separate Netron instance for each
concurrent authenticated principal; disconnect the old connection before reusing
its identity on the same server. IDs are limited to 256 characters without
whitespace/control characters. A greeting must fit in 4096 bytes and finish within
connectTimeout.
Netron owns reconnect together with the peer handshake. Raw transport reconnect is disabled for sockets it owns, preventing competing recovery loops. Stopping Netron cancels pending handshakes and reconnects; late results are closed.
Startup succeeds only when every configured server transport is available and has
started. If any listener fails, Netron waits for the other factories to settle,
then closes all created listeners and connections through the same cleanup path
as stop(). A late factory cannot leave a socket behind a failed startup.
WebSocket readiness waits for the native listener, including host resolution and
an external HTTP server. Bind errors reject startup instead of returning a ready
adapter. Port 0, in options or a ws://host:0 address, asks the OS for a free
port; it never selects the protocol default.
Concurrent stop() joins that lifecycle; a new start() or connect() is refused
while cleanup is running. The configuration remains available for a retry.
A close failure is reported rather than treated as a clean stop. Other resources
are still released; failed listeners remain registered so stop() can retry.
start() cannot claim readiness over a listener left by an incomplete cleanup.
Inspect the original startup error and any cleanup errors before retrying.
Persistent RPC connections stay leased until disconnect. Pool size, idle cleanup
and pool heartbeat options apply to unleased spare connections managed directly
through connectionManager; they neither create additional RPC sockets nor evict
an otherwise idle persistent peer. Transport keepalive owns its liveness.
TLS
The Netron transports do not currently expose a built-in TLS/tls
option. Terminate TLS at the load balancer, ingress, or reverse proxy
in front of the process — HTTP and WebSocket traverse those cleanly.
For TCP between hosts, run it over a private network or a tunnel.
Unix sockets need no transport encryption; their access control is filesystem permissions.
Same service, multiple transports — what changes?
Nothing in the service code. Same dispatch logic, same middleware stack, same error mapping. The transport is invisible to the method body.
A few client-side differences:
Browser can only use HTTP and WebSocket.
Streaming methods (returning
AsyncIterable<T>) require WS, TCP, or Unix — HTTP cannot stream.WS connection state persists across calls. The initial credential is revalidated before each invocation; revocation and role changes are not frozen at connection time. HTTP authenticates per request.
The transport registry
Transports are pluggable. Every Netron instance owns its own
registry (new TransportRegistry() in the constructor), which
auto-registers tcp, ws/websocket and unix. Add your own by
implementing the transport interface and registering it on the
instance:
import { Netron } from '@omnitron-dev/titan/netron';
// ITransport ships from the http entry point — that is currently its only
// public path, though the interface itself is transport-agnostic.
import type {
ITransport, ITransportConnection, ITransportServer,
TransportOptions, TransportAddress,
} from '@omnitron-dev/titan/netron/transport/http';
class QuicTransport implements ITransport {
readonly name = 'quic';
readonly capabilities = {
streaming: true, bidirectional: true, binary: true,
reconnection: true, multiplexing: true, server: true,
};
async connect(address: string, options?: TransportOptions): Promise<ITransportConnection> {
throw new Error('not implemented');
}
async createServer(options?: TransportOptions): Promise<ITransportServer> {
throw new Error('not implemented');
}
isValidAddress(address: string) { return address.startsWith('quic://'); }
parseAddress(address: string): TransportAddress {
return { protocol: 'quic', host: 'localhost', port: 4433 };
}
}
// registerTransport takes a *factory*, not the class itself.
netron.registerTransport('quic', () => new QuicTransport());
netron.registerTransportServer('quic', { name: 'quic', options: { port: 4433 } });registerTransportServer throws NotFound unless the name was
registered first, so the two calls are ordered.
Register custom transports on each Netron instance that serves them. The instance owns its transport registry.
Cross-runtime transport availability
| Runtime | HTTP | WebSocket | TCP | Unix |
| Node | ✓ | ✓ | ✓ | ✓ |
| Bun | ✓ | ✓ | ✓ | ✓ |
| Deno | ✓ | ✓ | ✓ | ✓ |
| Browser | ✓ | ✓ | — | — |
A fresh Netron already has tcp, ws/websocket and unix.
HTTP is never auto-registered — register it on the instance before
starting an HTTP server:
import { HttpTransport } from '@omnitron-dev/titan/netron/transport/http';
netron.registerTransport('http', () => new HttpTransport());
netron.registerTransportServer('http', { name: 'http', options: { port: 8081 } });For browsers, use the Netron browser client.
HTTP failures and deadlines
Titan and the browser client decode HTTP errors through the same
netron-protocol contract. Numeric, decimal-string and shared symbolic codes
retain their category on peer, direct connection and per-request-hint paths.
Unknown application symbols remain in details.errorCode, with the HTTP failure
status as the category. Request, correlation and trace headers survive decoding.
A 401/403 refusal must not turn into an internal error or a retryable outage.
A request deadline covers both response headers and body consumption. Each
concurrent request has its own cancellation controller; closing the connection
cancels every pending request. A valid request sent through connection.send()
propagates transport failures and remote refusals to its caller. A timeout means
that confirmation was not received, not that a remote mutation was undone.
Anti-patterns
Binding all four transports by default. Each open transport is a port and an attack surface. Bind only what's needed.
HTTP for service-to-service hot paths. Per-call framing cost dominates. Use TCP or WS within the cluster.
WebSocket for short-lived clients. A short-lived client that connects, makes one call, disconnects pays for the connection setup over and over. HTTP is simpler.
Leaving the packet ceiling at the 16 MiB default. Size
maxPacketSize(and HTTP'smaxRequestSize) down to what your contract actually needs — it defends against accidental memory amplification on untrusted-peer transports.