Skip to content

Transport

The transport layer handles how messages are sent and received between clients and servers. Teleportal’s transport system is built on async iterables, making it composable and allowing multiple transports to be chained together.

Teleportal uses async iterables (AsyncIterable<Message[]>) as the foundation for its transport layer rather than raw callbacks, event emitters, or Web Streams. This is a deliberate architectural choice that enables three properties:

  • Composability: Transports are just iterables and write functions, so middleware (encryption, rate limiting, logging, validation) can be layered by wrapping one transport in another. Each layer is independent and testable in isolation. You can stack them in any order without the middleware needing to know about each other.
  • Backpressure: The pull-based async iterable model naturally pauses the producer when the consumer is slow. The consumer pulls the next batch only when it is ready, preventing unbounded memory growth from buffered messages.
  • Runtime portability: Async iterables are a language-level primitive available in every JavaScript runtime — browsers, Bun, Deno, Node.js, and Cloudflare Workers. By building on a language standard rather than a runtime-specific abstraction, the transport layer works everywhere without polyfills.

This means that adding a new transport (say, WebTransport or a custom binary protocol) only requires implementing an async iterable source and a write function — all existing middleware automatically applies to it.

A Transport combines a Source (for reading messages) and a Sink (for writing messages):

type Source<Context> = {
source: AsyncIterable<Message<Context>[]>;
};
type Sink<Context> = {
write(message: Message<Context>): void | Promise<void>;
close(): void;
};
type Transport<Context> = Source<Context> & Sink<Context>;

The source yields batches (arrays) of messages — preserving the natural batching of the underlying transport (a single WebSocket frame might carry multiple protocol messages). The write function sends a single message. The close function tears down the transport.

The transport system follows a composable architecture:

  1. Base transports handle the actual communication (HTTP, SSE, WebSocket, PubSub)
  2. Middleware transports wrap other transports to add functionality (encryption, rate limiting, logging, validation)
  3. Utility functions help compose, connect, and transform transports

Throttles messages using a token bucket algorithm:

import { withRateLimit } from "teleportal/transports/rate-limiter";
const rateLimitedTransport = withRateLimit(transport, {
rules: [
{
id: "per-user",
maxMessages: 100,
windowMs: 1000,
trackBy: "user",
},
],
});

Wraps a transport with content-level end-to-end encryption:

import { getEncryptedTransport } from "teleportal/transports";
const encryptedTransport = getEncryptedTransport(handler);

You rarely call this directly — the Provider applies it automatically because content encryption is the default. Pass an encryptionKey (a CryptoKey) when creating the provider, or encryptionKey: false to opt a document out into plaintext. The encrypted transport keeps the CRDT structure update in plaintext (so the server can still merge and sync) and encrypts only the document content into sidecars.

Adds authorization checks to message reading and writing:

import { withMessageValidator } from "teleportal/transports";
const validatedTransport = withMessageValidator(transport, {
isAuthorized: async (message, type) => {
// Your authorization logic. Note: for server-side transports the framework
// always passes type "write" (both directions are checked with "write"),
// so derive read/write from the message payload, not the `type` argument.
return true;
},
});

Logs all messages for debugging:

import { withLogger } from "teleportal/transports";
const loggedTransport = withLogger(transport);

Adds acknowledgment message support for reliable message delivery:

import { withAckSink, withAckTrackingSink } from "teleportal/transports";
// Server: Send ACKs automatically
const ackSink = withAckSink(sink, {
pubSub,
ackTopic: "acks",
sourceId: "server-1",
});
// Client: Track ACKs
const trackingSink = withAckTrackingSink(sink, {
pubSub,
ackTopic: "acks",
sourceId: "client-1",
ackTimeout: 10000,
});

Transports can be composed in layers:

// Base transport
let transport = getBaseTransport();
// Add encryption
transport = getEncryptedTransport(handler);
// Add rate limiting
transport = withRateLimit(transport, { rules: myRules });
// Add logging
transport = withLogger(transport);
// Add message validation
transport = withMessageValidator(transport, {
isAuthorized: async (message, type) => {
// Authorization logic
return true;
},
});

The YDoc transport integrates Y.js documents with the Teleportal transport system:

import { getYTransportFromYDoc } from "teleportal/transports";
const transport = getYTransportFromYDoc({
ydoc,
awareness,
document: "my-document",
context: { clientId: "client-1" },
});
// Start synchronization
await transport.handler.start();

Combines a Source and Sink into a Transport:

import { compose } from "teleportal/transports";
const transport = compose(source, sink);

Drains all messages from a source and writes them to a sink (one-directional):

import { connect } from "teleportal/transports";
await connect(source, sink);

Bidirectionally connects two transports — each transport’s source feeds the other’s sink:

import { sync } from "teleportal/transports";
await sync(transportA, transportB);

Transform messages flowing through a source. Each helper takes the per-item function and returns a transform (source) => source that operates over the batched AsyncIterable (a fully-filtered batch is skipped, never yielded empty):

import { mapMessages, filterMessages, flatMapMessages } from "teleportal/transports";
// Transform each message (return null/undefined to drop it)
const mapped = mapMessages((msg) => transform(msg))(source);
// Drop messages that don't match a predicate
const filtered = filterMessages((msg) => msg.type === "doc")(source);
// Expand each message into zero or more outputs
const expanded = flatMapMessages((msg) => [msg, derivedMsg])(source);

Convert between message-level and binary transports:

import { toBinaryTransport, fromBinaryTransport } from "teleportal/transports";
// Message transport → binary (for wire transmission)
const binaryTransport = toBinaryTransport(transport, context);
// Binary transport → message (for protocol processing)
const messageTransport = fromBinaryTransport(binaryTransport, context);
  1. Compose from bottom up: Start with base transports, then add middleware
  2. Handle errors: Transports can error, ensure proper error handling
  3. Clean up resources: Call close() when done
  4. Use appropriate transports: Choose transports based on your use case
  5. Rate limit client transports: Always rate limit client-side transports to prevent abuse
  6. Validate messages: Use message validators for authorization checks
  7. Monitor with logging: Use logger transport during development