Socket RPC

socket-rpc
is a powerful command-line tool that automatically generates a type-safe RPC (Remote Procedure Call) layer for your client and server applications using socket.io
. It takes a TypeScript interface as input and generates all the necessary code for you to communicate between your client and server with full type safety. It's unopinionated, meaning it only generates the function bindings and doesn't interfere with your existing socket.io
configuration.
Features
- Type-Safe: Full static type checking for your RPC calls, powered by TypeScript.
- Auto-generation: Automatically generates client and server code from a single TypeScript interface definition.
- Unopinionated: Generates only the type-safe bindings, leaving you in full control of your
socket.io
setup.
- Bidirectional Communication: Supports both client-to-server and server-to-client RPC calls.
- Simple to Use: Get started with a single command.
- Error Handling: Built-in error handling with
RpcError
type.
Getting Started
The example
directory in this repository is a great starting point and can be used as a template to bootstrap your own project. It demonstrates a practical project structure that you can adapt for your needs.
1. Define Your RPC Interface
Create a TypeScript file (e.g., pkg/rpc/define.ts
) that defines the functions your server and client will expose.
interface ServerFunctions {
generateText: (prompt: string) => string;
}
interface ClientFunctions {
showError: (error: Error) => void;
askQuestion: (question: string) => string;
}
Important Note: Do not use Promise
in the return types when defining functions in your interfaces (ServerFunctions
, ClientFunctions
). The library automatically wraps the return types in Promise
. The implementation of these functions can be async
and return a Promise
, but the definition should specify the final resolved type. For example, use (prompt: string) => string
instead of (prompt: string) => Promise<string>
.
2. Run the Generator
Use the socketrpc-gen
CLI to generate the RPC code. The generator automatically infers the output directory from the input file path.
bunx socketrpc-gen <path-to-your-interface-file> [options]
For example:
bunx socketrpc-gen ./example/pkg/rpc/define.ts
This will generate a new package in the example/pkg/rpc
directory containing the generated client and server code.
Example Usage
sequenceDiagram
participant ClientApp as "Your Client Application"
participant GenClient as "Generated Client-Side RPC"
participant GenServer as "Generated Server-Side RPC"
participant ServerApp as "Your Server Application"
title socket-rpc: Bidirectional Communication Flow
ClientApp->>GenClient: 1. Calls `generateText("hello")`
activate GenClient
GenClient->>GenServer: 2. Emits "rpc:generateText" event over network
deactivate GenClient
activate GenServer
GenServer->>ServerApp: 3. Invokes your `generateText` handler
activate ServerApp
Note over ServerApp: Server logic decides to<br/>call a function on the client
ServerApp->>GenServer: 4. Calls `askQuestion("Favorite color?")`
GenServer->>GenClient: 5. Emits "rpc:askQuestion" event over network
deactivate GenServer
activate GenClient
GenClient->>ClientApp: 6. Invokes your `askQuestion` handler
activate ClientApp
ClientApp-->>GenClient: 7. Returns answer: "blue"
deactivate ClientApp
GenClient-->>GenServer: 8. Sends response ("blue") back to server
deactivate GenClient
activate GenServer
GenServer-->>ServerApp: 9. `askQuestion` promise resolves with "blue"
Note over ServerApp: Server finishes its logic and<br/>returns the final result
ServerApp-->>GenServer: 10. Returns final result for `generateText`
deactivate ServerApp
GenServer-->>GenClient: 11. Sends final result back to client
deactivate GenServer
activate GenClient
GenClient-->>ClientApp: 12. Original `generateText` promise resolves
deactivate GenClient
Server
Implement the server-side functions and use the generated handlers to process client requests.
import { createServer } from "http";
import { Server } from "socket.io";
import {
handleGenerateText,
showError,
askQuestion,
} from "@socket-rpc/rpc/server.generated";
import { RpcError, isRpcError } from "@socket-rpc/rpc";
const httpServer = createServer();
const io = new Server(httpServer);
io.on("connection", async (socket) => {
handleGenerateText(
socket,
async (prompt: string): Promise<string | RpcError> => {
try {
const clientResponse = await askQuestion(
socket,
"What is your favorite color?",
3000
);
if (isRpcError(clientResponse)) {
console.error("Client returned an error:", clientResponse.message);
} else {
console.log(`Client's favorite color is: ${clientResponse}`);
}
} catch (e) {
console.error("Did not get a response from client for askQuestion", e);
}
showError(socket, new Error("This is a test error from the server!"));
if (prompt === "error") {
return {
code: "custom_error",
message: "This is a custom error",
data: { a: 1 },
} as RpcError;
} else if (prompt === "throw") {
throw new Error("This is a thrown error");
}
return `Server received: ${prompt}`;
}
);
});
httpServer.listen(8080, () => {
console.log("Server running on http://localhost:8080");
});
Client
Use the generated functions to call server methods and handle server-initiated calls.
import { io } from "socket.io-client";
import {
generateText,
handleShowError,
handleAskQuestion,
} from "@socket-rpc/rpc/client.generated";
import { isRpcError } from "@socket-rpc/rpc";
const socket = io("http://localhost:8080");
handleShowError(socket, async (error: Error): Promise<void> => {
console.error("Server sent an error:", error.message);
});
handleAskQuestion(socket, async (question: string) => {
console.log(`Server asked: ${question}`);
return "blue";
});
socket.on("connect", async () => {
console.log("Connected to the server!");
const response = await generateText(socket, "Hello, server!", 10000);
if (isRpcError(response)) {
console.error("RPC Error:", response);
} else {
console.log("Server responded:", response);
}
});
socket.on("disconnect", (reason) => {
console.log(`Disconnected from server: ${reason}`);
});
CLI Reference
socketrpc-gen
Generates the RPC code from interface definitions.
Usage:
socketrpc-gen <path> [options]
Arguments:
<path>
: Path to the input TypeScript file containing interface definitions. (Required)
Options:
-p, --package-name <name>
: The npm package name for the generated RPC code. (Default: "@socket-rpc/rpc")
-t, --timeout <ms>
: Default timeout in milliseconds for RPC calls that expect a response. This can be overridden per-call. (Default: "5000")
-w, --watch
: Watch for changes in the definition file and regenerate automatically. (Default: false)
-h, --help
: Display help for command.
How It Works
The socket-rpc
tool works by parsing your TypeScript interface file and generating a set of functions and handlers that wrap the socket.io
communication layer.
- For each function in your
ServerFunctions
interface, it generates:
- A
handle<FunctionName>
function for the server to process incoming requests.
- A
<functionName>
function for the client to call the server method.
- For each function in your
ClientFunctions
interface, it generates:
- A
handle<FunctionName>
function for the client to process incoming requests from the server.
- A
<functionName>
function for the server to call the client method.
This approach provides a clean and type-safe way to communicate between your client and server, without having to write any boilerplate socket.io
code yourself. It automatically handles acknowledgments for functions that return values and uses fire-and-forget for void
functions.
Common Patterns
Sync vs Async Communication
Synchronous Pattern (Request-Response)
Use this pattern when you need to wait for a response:
interface ServerFunctions {
getData: (id: string) => UserData;
}
Asynchronous Pattern (Fire-and-Forget with Callback)
Use this pattern for streaming or progressive updates. Declare the server function as void
and create a client callback to receive responses:
interface ServerFunctions {
startStreaming: (topic: string) => void;
}
interface ClientFunctions {
onStreamData: (data: StreamChunk) => void;
onStreamEnd: () => void;
}
Streaming Simulation Example
Here's a complete example showing how to simulate streaming data from server to client:
1. Interface Definition (pkg/rpc/define.ts
)
interface StreamChunk {
id: number;
content: string;
timestamp: number;
}
interface ServerFunctions {
startDataStream: (topic: string) => void;
stopDataStream: () => void;
}
interface ClientFunctions {
onStreamChunk: (chunk: StreamChunk) => void;
onStreamComplete: (totalChunks: number) => void;
onStreamError: (error: string) => void;
}
2. Server Implementation
import {
handleStartDataStream,
handleStopDataStream,
onStreamChunk,
onStreamComplete,
onStreamError
} from "@socket-rpc/rpc/server.generated";
io.on("connection", (socket) => {
let streamInterval: NodeJS.Timeout | null = null;
handleStartDataStream(socket, async (topic: string) => {
console.log(`Starting stream for topic: ${topic}`);
let chunkId = 0;
const maxChunks = 10;
streamInterval = setInterval(async () => {
if (chunkId >= maxChunks) {
clearInterval(streamInterval!);
streamInterval = null;
onStreamComplete(socket, maxChunks);
return;
}
const chunk: StreamChunk = {
id: chunkId++,
content: `Data chunk for ${topic} #${chunkId}`,
timestamp: Date.now()
};
onStreamChunk(socket, chunk);
}, 500);
});
handleStopDataStream(socket, async () => {
if (streamInterval) {
clearInterval(streamInterval);
streamInterval = null;
console.log("Stream stopped by client request");
}
});
socket.on("disconnect", () => {
if (streamInterval) {
clearInterval(streamInterval);
}
});
});
3. Client Implementation
import {
startDataStream,
stopDataStream,
handleOnStreamChunk,
handleOnStreamComplete,
handleOnStreamError
} from "@socket-rpc/rpc/client.generated";
const socket = io("http://localhost:8080");
handleOnStreamChunk(socket, async (chunk: StreamChunk) => {
console.log(`Received chunk ${chunk.id}: ${chunk.content} at ${new Date(chunk.timestamp).toISOString()}`);
});
handleOnStreamComplete(socket, async (totalChunks: number) => {
console.log(`Stream completed! Received ${totalChunks} chunks total.`);
});
handleOnStreamError(socket, async (error: string) => {
console.error("Stream error:", error);
});
socket.on("connect", () => {
startDataStream(socket, "user-activity");
setTimeout(() => {
stopDataStream(socket);
}, 8000);
});
This pattern enables real-time data streaming while maintaining type safety. The server uses fire-and-forget functions to initiate streams, then uses client callback functions to progressively send data chunks.