File Transfers
Teleportal supports file uploads and downloads alongside document sync, using chunked streaming with Merkle tree integrity verification. Files are transferred as chunks (default 1MB) and can be end-to-end encrypted with the same key used for document content.
What it demonstrates
Section titled “What it demonstrates”- Setting up FileStorage and TemporaryUploadStorage on the server
- Registering file RPC handlers
- Uploading and downloading files from the client
- Integrity verification via Merkle proofs
- Encrypted file transfers
- Persistent chunk caching, retransmission, and progress events
Server Setup
Section titled “Server Setup”File transfers require a FileStorage and a TemporaryUploadStorage (for in-progress uploads). Register the file RPC handlers with getFileRpcHandlers():
import { Server } from "teleportal/server";import { MemoryDocumentStorage, InMemoryFileStorage, InMemoryTemporaryUploadStorage,} from "teleportal/storage";import { getFileRpcHandlers } from "teleportal/protocols/file";
const fileStorage = new InMemoryFileStorage();fileStorage.temporaryUploadStorage = new InMemoryTemporaryUploadStorage();
const server = new Server({ storage: async (ctx) => new MemoryDocumentStorage(ctx.encrypted), rpcHandlers: { ...getFileRpcHandlers(fileStorage), },});Configuring Chunk Size
Section titled “Configuring Chunk Size”The server controls the wire chunk size and communicates it to clients during upload initialization. The default is 1MB; adjust it to balance throughput and memory:
const server = new Server({ storage: async (ctx) => new MemoryDocumentStorage(ctx.encrypted), rpcHandlers: { ...getFileRpcHandlers(fileStorage, { chunkSize: 256 * 1024 }), // 256KB chunks },});Permission Checking
Section titled “Permission Checking”Add upload and download permission checks to control who can transfer files:
import { getFileRpcHandlers, type FileHandlerOptions } from "teleportal/protocols/file";
const options: FileHandlerOptions = { async checkUploadPermission(fileId, metadata, context) { const allowed = await checkAccess(context.userId, context.documentId, "write"); return allowed ? { allowed: true } : { allowed: false, reason: "No write access" }; }, async checkDownloadPermission(fileId, context) { const allowed = await checkAccess(context.userId, context.documentId, "read"); return allowed ? { allowed: true } : { allowed: false, reason: "No read access" }; },};
const server = new Server({ storage: async (ctx) => new MemoryDocumentStorage(ctx.encrypted), rpcHandlers: { ...getFileRpcHandlers(fileStorage, options), },});Client Setup
Section titled “Client Setup”Register the file RPC extension on the provider. When encryption is enabled (the default), pass the same encryptionKey to createFileRpc so files are encrypted with the same key as the document:
import { Provider } from "teleportal/providers";import { createFileRpc } from "teleportal/protocols/file";import { createEncryptionKey } from "teleportal/encryption-key";
const encryptionKey = createEncryptionKey();
const provider = await Provider.create({ url: "wss://example.com/sync", document: "my-doc", encryptionKey, rpc: { file: () => createFileRpc({ encryptionKey }), },});Uploading a File
Section titled “Uploading a File”Pass a File object (from an <input> element or the File API) to upload():
const input = document.querySelector<HTMLInputElement>("#file-input");input.addEventListener("change", async () => { const file = input.files?.[0]; if (!file) return;
const fileId = await provider.rpc.file.upload(file); console.log("Uploaded:", fileId);});Downloading a File
Section titled “Downloading a File”Retrieve a file by its ID. download() returns a native File object (decrypted client-side when encryption is enabled):
const file = await provider.rpc.file.download(fileId);// file is a File: file.name, file.size, file.type, await file.arrayBuffer(), etc.download() accepts an optional second argument to override the default per-operation behavior – { timeout } (download timeout in ms, default 60000), { encryptionKey } to override the extension’s key, and { cache: false } to bypass the persistent cache:
const file = await provider.rpc.file.download(fileId, { timeout: 10_000, cache: false });Error Handling
Section titled “Error Handling”File operations throw RpcOperationError on failure:
import { RpcOperationError } from "teleportal/rpc";
try { await provider.rpc.file.upload(myFile);} catch (error) { if (error instanceof RpcOperationError) { console.error(error.protocol); // "file" console.error(error.operation); // "upload" }}How It Works
Section titled “How It Works”Chunked Transfer
Section titled “Chunked Transfer”Files are split into chunks (sized by the server, default 1MB) and transferred over the RPC layer as an ordered sequence of chunk messages. Chunking lets each chunk be encrypted, hashed, verified, and acknowledged independently, and makes uploads resumable and content-addressed.
Upload flow: The client encrypts the whole file and folds its Merkle root up front — the root is the content ID, which lets the server answer “already have it” (dedup) or “here are the chunks I’m missing” (resume) in a single round-trip. The client then streams the missing chunks (pipelined, not strictly one-ACK-at-a-time) and resolves once every sent chunk is acknowledged. After the last chunk lands, the server rebuilds the Merkle tree, verifies it against the claimed content ID, and moves the file from temporary to durable storage.
Download flow: The client sends a fileDownload request. The server streams chunks back, each with a Merkle proof. The client verifies each chunk against the root hash before accepting it, then assembles the file once all chunks arrive.
Merkle Tree Verification
Section titled “Merkle Tree Verification”Every file is content-addressed using a Merkle tree built from its chunks. The tree hashes the ciphertext chunks (for encrypted files), so on download the client verifies each chunk’s Merkle proof against the root hash before decrypting it – verification and decryption run concurrently, but a chunk that fails its proof is rejected without its plaintext ever being trusted. This guarantees that no chunk has been tampered with or corrupted in transit or at rest.
On upload, the server rebuilds the Merkle tree from the received chunks and derives the authoritative content ID (the upload stream itself omits per-chunk proofs – the server recomputes and verifies the root at completion). The download path is the integrity boundary where per-chunk proofs are checked.
Encrypted File Transfers
Section titled “Encrypted File Transfers”When you pass an encryptionKey to createFileRpc, files are encrypted client-side before upload and decrypted client-side after download, using the same AES-256-GCM key that protects the document. The server never sees plaintext file content.
Caching
Section titled “Caching”Pass a FileCache (such as the IndexedDB-backed IdbFileCache) to persist chunks across reloads:
import { createFileRpc } from "teleportal/protocols/file";import { IdbFileCache } from "teleportal/storage";
const provider = await Provider.create({ url: "wss://example.com/sync", document: "my-doc", encryptionKey, rpc: { file: () => createFileRpc({ encryptionKey, cache: new IdbFileCache() }), },});Uploads are cached optimistically (keyed by content ID) as soon as the server accepts the request. Downloads are served straight from the cache when every chunk is present – with no server round-trip – while a partial cache entry falls through to the server. Chunks are cached in their verified ciphertext form (before decryption). Pass { cache: false } to upload() or download() to bypass the cache for a single operation.
Retransmission
Section titled “Retransmission”Uploads are ACK-driven: the client resolves once every streamed chunk is positively acknowledged. The server can nack a chunk (ACK with retryAfter) to shed load, and the client runs a single background retransmit loop with exponential backoff (starting at max(retryAfter, 200)ms, doubling up to 10s, for up to 8 rounds), resending only still-unacked chunks. File chunks also get their own rate-limit budget on the server (they are recognized as file-transfer messages).
Progress Events
Section titled “Progress Events”Subscribe to lightweight, numbers-only progress snapshots via onFileTransferProgress:
import { onFileTransferProgress } from "teleportal/protocols/file";
const unsubscribe = onFileTransferProgress((p) => { // p: { fileId, document, direction, chunksTransferred, totalChunks?, bytesTransferred, status, error? } console.log(`${p.direction} ${p.fileId}: ${p.chunksTransferred}/${p.totalChunks} (${p.status})`);});status is "active", "complete", or "error"; bytesTransferred is an approximate plaintext byte count.
Next Steps
Section titled “Next Steps”- Encryption Keys - Key creation, sharing, and management
- Custom RPC Methods - Add your own RPC operations
- Custom Storage - Implement FileStorage for S3, PostgreSQL, etc.