socketrpc-gen
v8.0.0
Published
Code generator for Socket.IO RPC packages using ts-morph.
Downloads
115
Maintainers
Readme
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.
- Ergonomic API: Clean
client.handle.*/client.server.*pattern with automatic cleanup. - Unopinionated: Generates only the type-safe bindings, leaving you in full control of your
socket.iosetup. - Bidirectional Communication: Supports both client-to-server and server-to-client RPC calls.
- Multi-Language: Generate a TypeScript client with either a TypeScript or a Go server, from one contract.
- Simple to Use: Get started with a single command.
- Robust Error Handling: Branded
RpcError(no shape collisions), standard error codes (TIMEOUT,DISCONNECTED,ABORTED, …), and a per-callreturn-or-throwerror mode. - Connection-Aware:
connectedstate plusonConnect/onDisconnect/onReconnecthooks for re-syncing after reconnects. - Cancellable Calls: Per-call
AbortSignalandtimeout, plusvolatileto drop (instead of buffer) calls made while offline. - Granular Cleanup: Every handler registration returns an unsubscribe function; re-registering a handler replaces the previous one.
Getting Started
The examples/00-full-app directory in this repository is a complete working application that can be used as a template to bootstrap your own project. It demonstrates a practical project structure with actual client/server implementation that you can adapt for your needs.
Examples
Check out the examples/ directory for comprehensive examples:
- 00-full-app - Complete working application with client/server implementation
- 01-basic - Simple interface definitions without extension
- 02-single-extension - Single-level interface inheritance
- 03-multi-level-extension - Multi-layer architecture patterns
- 05-go-server - TypeScript client with a Go server
See the examples README for detailed comparisons and use cases.
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.
// pkg/rpc/define.ts
/**
* Interface defining the functions available on the RPC server
* These functions can be called remotely by RPC clients
*/
interface ServerFunctions {
/**
* Generates text based on the provided prompt
*/
generateText: (prompt: string) => string;
}
/**
* Interface defining the functions available on the RPC client
* These functions can be called by the RPC server to interact with the client
*/
interface ClientFunctions {
/**
* Displays an error to the client user interface
*/
showError: (error: Error) => void;
/**
* Asks the client a question and expects a response.
* @param question The question to ask the client.
* @returns The client's answer to the question.
*/
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 ./examples/00-full-app/pkg/rpc/define.tsThis will generate a new package in the examples/00-full-app/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 `rpc.server.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 `rpc.client.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 GenClientServer
Use createRpcServer() to create an ergonomic server instance with .handle for handlers and .client for client methods.
// pkg/server/index.ts
import { createServer } from "http";
import { Server } from "socket.io";
import { createRpcServer } from "@socket-rpc/rpc/server.generated";
import { isRpcError, rpcError } from "@socket-rpc/rpc";
const httpServer = createServer();
const io = new Server(httpServer);
io.on("connection", async (socket) => {
const rpc = createRpcServer(socket);
// Handle the `generateText` RPC call from the client.
// Handlers resolve with the value on success, or **throw** to signal an error.
rpc.handle.generateText(async (prompt) => {
// Example of server calling a client function and waiting for a response
const clientResponse = await rpc.client.askQuestion("What is your favorite color?");
if (isRpcError(clientResponse)) {
console.error("Client returned an error:", clientResponse.message);
} else {
console.log(`Client's favorite color is: ${clientResponse}`);
}
// Example of server calling a fire-and-forget client function
rpc.client.showError(new Error("This is a test error from the server!"));
if (prompt === "error") {
// Throw a typed, branded RpcError (with an optional data payload).
throw rpcError("custom_error", "This is a custom error", { a: 1 });
} else if (prompt === "throw") {
// Any thrown value is normalized into an RpcError (code INTERNAL_ERROR).
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 createRpcClient() to create an ergonomic client instance. Call .dispose() to clean up all handlers.
// pkg/client/index.ts
import { io } from "socket.io-client";
import { createRpcClient } from "@socket-rpc/rpc/client.generated";
import { isRpcError } from "@socket-rpc/rpc";
const socket = io("http://localhost:8080");
const rpc = createRpcClient(socket);
// Register handlers using rpc.handle.* (for calls FROM server)
rpc.handle.showError(async (error) => {
console.error("Server sent an error:", error.message);
});
rpc.handle.askQuestion(async (question) => {
console.log(`Server asked: ${question}`);
return "blue"; // Answer the server's question
});
// Make RPC calls using rpc.server.* (calls TO server)
socket.on("connect", async () => {
console.log("Connected to the server!");
const response = await rpc.server.generateText("Hello, server!");
if (isRpcError(response)) {
console.error("RPC Error:", response);
} else {
console.log("Server responded:", response);
}
});
// Clean up when done (e.g., on page unload)
window.addEventListener("beforeunload", () => {
rpc.dispose();
});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")-l, --error-logger <path>: Custom error logger import path (e.g., '@/lib/logger'). The module must default-export(message: string, ...args: unknown[]) => void. By default usesconsole.error.-e, --error-mode <mode>: How call methods surface failures —returntheRpcError(default, check withisRpcError) orthrowit (usetry/catch).-c, --client <language>: Language of the generated client. (Default: "typescript")-s, --server <language>: Language of the generated server —typescriptorgo. (Default: "typescript")--go-package <name>: Go package clause for the generated server. (Default: "rpc")--go-out <dir>: Directory for the generated Go files. (Default: the input file's directory)--go-socket-import <path>: Go Socket.IO server import path the bindings are written against. (Default: "github.com/zishang520/socket.io/servers/socket/v3")-w, --watch: Watch for changes in the definition file and regenerate automatically. (Default: false)-h, --help: Display help for command.
Framework Integration
The generated createRpcClient() / createRpcServer() returns an object with a .dispose() method that cleans up all handlers. Integrate this with your framework's lifecycle hooks.
Vue 3 Composition API
import { onBeforeUnmount } from 'vue';
import { socket } from './socket';
import { createRpcClient } from './rpc/client.generated';
export default {
setup() {
const rpc = createRpcClient(socket);
// Register handlers - no manual cleanup tracking needed!
rpc.handle.showError(async (error) => {
console.error('Error:', error);
});
rpc.handle.askQuestion(async (question) => {
return 'blue';
});
// Single cleanup call handles everything
onBeforeUnmount(() => rpc.dispose());
return { rpc };
}
}React Hooks
import { useEffect, useRef } from 'react';
import { socket } from './socket';
import { createRpcClient, RpcClient } from './rpc/client.generated';
function MyComponent() {
const rpcRef = useRef<RpcClient>();
useEffect(() => {
const rpc = createRpcClient(socket);
rpcRef.current = rpc;
// Register handlers
rpc.handle.showError(async (error) => {
console.error('Error:', error);
});
rpc.handle.askQuestion(async (question) => {
return 'blue';
});
// Cleanup on unmount
return () => rpc.dispose();
}, []);
return <div>My Component</div>;
}Plain JavaScript/TypeScript
import { socket } from './socket';
import { createRpcClient } from './rpc/client.generated';
const rpc = createRpcClient(socket);
// Register handlers (for calls FROM server)
rpc.handle.showError(async (error) => {
console.error('Error:', error);
});
// Make calls (TO server)
const result = await rpc.server.generateText("Hello!");
// Clean up when done
rpc.dispose();API Reference
createRpcClient(socket)
Creates an ergonomic client RPC interface.
Returns: RpcClient with the following properties:
| Property | Type | Description |
|----------|------|-------------|
| handle | RpcClientHandle | Register handlers for server-to-client calls. Each registration returns an unsubscribe function |
| server | RpcClientRemote | Call server methods |
| socket | Socket | The underlying socket instance |
| connected | boolean | Whether the underlying socket is currently connected |
| onConnect(handler) | (() => void) => Unsubscribe | Run a handler on every (re)connect — re-sync / re-auth here |
| onDisconnect(handler) | ((reason: string) => void) => Unsubscribe | Run a handler whenever the socket disconnects |
| onReconnect(handler) | ((attempt: number) => void) => Unsubscribe | Run a handler after a successful reconnect |
| onRpcError(handler) | ((error: RpcError) => void) => Unsubscribe | Run a handler for errors the server reports from a fire-and-forget handler |
| disposed | boolean | Whether this instance has been disposed |
| dispose() | () => void | Cleanup all registered handlers |
createRpcServer(socket)
Creates an ergonomic server RPC interface.
Returns: RpcServer with the following properties:
| Property | Type | Description |
|----------|------|-------------|
| handle | RpcServerHandle | Register handlers for client-to-server calls. Each registration returns an unsubscribe function |
| client | RpcServerRemote | Call client methods |
| socket | Socket | The underlying socket instance |
| connected | boolean | Whether the underlying socket is currently connected |
| onDisconnect(handler) | ((reason: string) => void) => Unsubscribe | Run a handler when this socket disconnects |
| onRpcError(handler) | ((error: RpcError) => void) => Unsubscribe | Run a handler for errors the client reports from a fire-and-forget handler |
| disposed | boolean | Whether this instance has been disposed |
| dispose() | () => void | Cleanup all registered handlers |
Two registration conventions
The API has exactly two, and they differ on purpose:
| | Where | Semantics |
|---|---|---|
| RPC methods | rpc.handle.<method>(handler) | One handler per method. Re-registering replaces the previous one, so HMR, React StrictMode, and remounts never double-answer an ack. |
| Events | rpc.on<Event>(handler) | Additive. A second subscriber runs alongside the first, in registration order. Covers onConnect, onDisconnect, onReconnect, onRpcError. |
Both return an unsubscribe function, and dispose() clears everything registered either way.
The Go backend follows the same split: one ServerHandler method per RPC method, and
OnRpcError additive with an unsubscribe return.
Naming your methods
What the generator enforces. Nothing under handle, server, or client is a built-in, so an
RPC method may use any name socket.io accepts — including dispose, handle, socket, connected
and friends, which live one level up on RpcClient/RpcServer and cannot be shadowed. The names
the generator rejects are socket.io's own reserved events, because emitting them throws at runtime:
connect · connect_error · disconnect · disconnecting · newListener · removeListenerThe Go backend rejects the same set, plus __rpc:error__, and keeps its own identifiers out of
reach structurally rather than by a denylist — see GENERATED_PREFIX in src/go/names.ts.
What the generator leaves to you. The rest is convention, and the wrappers give you a head start:
the interface a method is declared on fixes its direction, handle. marks registration, .server. /
.client. mark invocation, and a non-void return type is what makes a call awaitable. A name that
re-states any of those is paying rent twice.
| Style | Reads as | Good for |
|-------|----------|----------|
| Verb-first — getUser, showError, deleteJob | rpc.server.getUser(id) — plainly a callrpc.handle.getUser(fn) — plainly a registration | The default. Correct at both sites, no matter the direction. |
| on-prefixed — onMessage, onProgress | rpc.client.onMessage(text) — looks like subscribing, is actually invoking | Push notifications the receiver may ignore, where the on reads as part of the domain vocabulary. |
Prefer verb-first, and reach for on* when a method is genuinely a notification. Either way, skip
prefixes that duplicate what the wrapper already says — send*, call*, request*, handle*,
rpc*. The examples in this repo use both styles, so you can compare them side by side.
export interface ServerFunctions {
getUser: (userId: string) => User; // awaitable — non-void return
deleteUser: (userId: string) => void; // fire-and-forget — void return
}
export interface ClientFunctions {
showError: (error: Error) => void; // a command: "show this"
onProgress: (done: number) => void; // a notification: "this happened"
}Go spells the same contract idiomatically: getUser becomes HandleGetUser on ServerHandler,
showError becomes CallShowError on Client, and roomId becomes RoomID.
Typing raw socket usage
types.generated.ts also exports ClientToServerEvents and ServerToClientEvents, which describe
every RPC method as a socket.io event. They are optional — apply them when you touch the socket
directly alongside the RPC layer:
import type { ClientToServerEvents, ServerToClientEvents } from './rpc/types.generated';
// server
const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer);
// client
const socket = io<ServerToClientEvents, ClientToServerEvents>(url);They live in types.generated.ts rather than in each side file, so a module that imports both the
client and the server gets one definition instead of a name clash.
Connection, Reconnect & Cancellation
The generated RPC layer is connection-aware and composes with socket.io's reconnection.
Re-syncing after a reconnect
Inbound handlers registered with rpc.handle.* keep working across reconnects (the client reuses
the same socket). To re-run logic on every (re)connect — re-subscribe, re-authenticate, replay
state — use the connection hooks instead of reaching into rpc.socket:
const rpc = createRpcClient(socket);
rpc.onConnect(() => {
// runs on first connect AND every reconnect
rpc.server.subscribe(currentRoom);
});
rpc.onDisconnect((reason) => console.warn("offline:", reason));
rpc.onReconnect((attempt) => console.info("reconnected after", attempt, "attempts"));Calls made while disconnected
A value-returning call issued while offline is buffered by socket.io and flushed on reconnect;
if no reconnect completes within the timeout it resolves to an RpcError with code TIMEOUT. A
call interrupted by a disconnect resolves with code DISCONNECTED (distinct from a server-side
INTERNAL_ERROR, so you can safely retry). Pass volatile: true to drop a call instead of
buffering it — use this for real-time or non-idempotent calls that must not be replayed:
await rpc.server.getQuote(symbol, { volatile: true }); // skipped entirely if offlineTimeouts and cancellation
Every value-returning call accepts per-call options:
const controller = new AbortController();
const result = await rpc.server.search(query, {
timeout: 2000, // override the default timeout for this call
signal: controller.signal, // abort → resolves/throws an ABORTED RpcError
});
// controller.abort() stops awaiting the acknowledgement.Error modes
By default calls return T | RpcError and you narrow with isRpcError. Generate with
--error-mode throw to instead get Promise<T> that rejects with the RpcError, so you can
use try/catch:
// generated with --error-mode throw
try {
const user = await rpc.server.getUser(id); // typed as Promise<User>
} catch (e) {
if (isRpcError(e)) console.error(e.code, e.message);
}Go Server
A TypeScript client can talk to a Go server generated from the same define.ts:
bunx socketrpc-gen ./rpc/define.ts --client typescript --server go --go-out ./rpc/goThat emits client.generated.ts + types.generated.ts for the browser and
types.generated.go + server.generated.go for the server. No
server.generated.ts is produced. The Go bindings are written against
zishang520/socket.io/servers/socket/v3
and are gofmt-clean, go vet-clean and race-clean out of the box.
A contract method getUser is implemented as HandleGetUser and called as
CallGetUser: the generated APIs prefix the contract's own names, so a method
may be called scan, marshalJSON or dispose without meeting a name Go has
already claimed. Wire event names are the contract and are unaffected.
type handler struct{ client *rpc.Client }
func (h *handler) HandleGetUser(ctx context.Context, userID string) (rpc.User, error) {
if userID == "" {
// Any error becomes an RpcError on the wire; return an *RpcError for a typed one.
return rpc.User{}, rpc.NewRpcError(rpc.CodeInvalidArgument, "userID is required", "", nil)
}
return rpc.User{ID: userID, Name: "Ada"}, nil
}
func serve(raw *socket.Socket) {
client, _ := rpc.NewClient(raw, nil) // calls INTO the TypeScript client
binding, _ := rpc.BindServer(raw, &handler{client: client})
go func() { <-binding.Context().Done(); client.Dispose() }()
}What the Go backend accepts
The TypeScript backend reads your signatures as written, so it accepts anything
TypeScript accepts. The Go backend reads the portable RpcSchema IR, so it only
accepts contracts with a sound Go spelling — and names the declaration to write
when it refuses one:
| Accepted | Becomes in Go |
| --- | --- |
| string / number / boolean | string / float64 / bool |
| named type/interface object | an exported struct with JSON tags |
| named string-literal union | a string enum with Valid/MarshalJSON/UnmarshalJSON |
| T[], Record<string, T> | []T, map[string]T |
| T \| null, optional field? | *T (plus ,omitempty for optional fields) |
| unknown | any — an arbitrary JSON value; see below |
| void return | a fire-and-forget method |
Refused, with the fix named in the error: inline object literals, inline string
unions, ambient host types (Error, Date, Map), tuples, intersections,
generics, any, and optional positional parameters — an omitted trailing
argument is indistinguishable from a Socket.IO ack callback.
any is refused because it switches TypeScript's checking off at the call site,
so a contract using it is unchecked at both ends. Declare unknown for data
whose shape the contract does not fix; TypeScript then refuses every operation on
the value until the receiver narrows it.
Identifiers are derived idiomatically, so no per-field overrides are needed:
id → ID, roomId → RoomID, apiUrl → APIURL.
JSON values
Some contracts carry data whose shape belongs to the data rather than to the
contract — a document's frontmatter, one key of a patch. unknown states exactly
that, and Go spells it any:
export type Frontmatter = Record<string, unknown>; // map[string]any
export type Mutation = {
key: string;
value: unknown; // Value any `json:"value"`
previous?: unknown; // Previous any `json:"previous,omitempty"`
};func (h *handler) ApplyMutation(ctx context.Context, mutation rpc.Mutation) (rpc.Frontmatter, error) {
// Nothing here needs to know the shape of mutation.Value — that is the point.
h.frontmatter[mutation.Key] = mutation.Value
return h.frontmatter, nil
}Every JSON shape reaches the handler as the Go value that spells it:
map[string]any, []any, string, float64, bool, and nil for null.
The JSON data model already contains null, so a JSON value is nullable as it
stands: unknown | null is the same type, and any needs no pointer to hold
nil. An optional key is tagged ,omitempty and disappears when it is nil, which
means Go cannot tell "key omitted" from "key set to null" in an any — declare
a required unknown when an explicit null has to survive the trip.
Containers keep their normalization: a required Record<string, unknown> result
or field still arrives as {} rather than null, and unknown[] as []. A value
Go can hold in an any but JSON cannot encode — a channel, a func, a NaN —
comes back as an INTERNAL_ERROR naming it, as below.
One key is spoken for: __rpcError is the brand that tells a failure from a
value in an acknowledgement, so a JSON value carrying it at its top level is read
as an error by both sides. Nest such data one level down if it has to survive
verbatim.
Behaviour parity
The Go server gives each RPC method its own serialized dispatch queue: repeated calls to one method are handled in the order Socket.IO delivered them, matching the TypeScript server, while a blocked handler stalls only its own method. Calls to different methods run concurrently, so contracts that need cross-method ordering should carry an explicit sequence number.
Values a client's type says are arrays or objects arrive as [] / {} rather
than null, wherever Go's zero value would otherwise be nil: a returned slice or
map, a named alias for one (type Tags = string[]), and every required slice or
map field of a returned struct — including structs nested inside another struct,
a slice or a map. Fields the contract declares optional or nullable are pointers
and keep their null, because there the contract asks for it. Normalization runs
on a copy, so a handler never sees its own value change. A nil slice held
inside another slice or map (string[][], Record<string, string[]>) keeps
Go's nil, since replacing it would write through the caller's backing array.
A payload the JSON encoder refuses comes back as an INTERNAL_ERROR naming the
offending value — a required string enum left at its zero value, or a Go runtime
value placed in an any that JSON has no spelling for. Socket.IO's write path
discards encoding failures, so without that check the caller would wait out its
own timeout with nothing to go on.
binding.OnRpcError(func(*rpc.RpcError)) observes the failures the peer reports
out of band: a TypeScript client whose handler for a fire-and-forget
server-to-client call throws has no acknowledgement to answer through, so it
emits the error instead. This mirrors rpc.handle.rpcError(...) on the
TypeScript server.
Every identifier the generator declares in a scope your contract also reaches
carries an rpc_ prefix, and identifiers derived from your contract can never
contain an underscore — the two namespaces are disjoint by construction. A
parameter may therefore be called result, err, fmt, string or len, and
a method the client calls may be named Disconnect or _disconnect, without
consequence.
Method names get their own namespace instead of a prefix on the generator's
side: Handle… on ServerHandler, Call… on Client. Both shapes are a fixed
word followed by an upper-case letter, which no method name the standard library
has claimed is spelled as — so a contract may name a method scan, seek,
marshalJSON or unwrap and the package still passes go vet with no analyzer
excluded. The same prefix keeps Client's own Socket, Done, Connected and
Dispose out of reach, so those are legal method names too.
Three kinds of name are refused, each with the reason and the fix in the message:
ctxas a parameter — it is the context parameter of the generatedServerHandlerandClientsignatures.- Socket.IO's own event names (
connect,disconnect,disconnecting,connect_error,newListener,removeListener) as method names. - A declaration whose Go name is one the generated package already exports, such
as
ServerHandlerorClientOptions.
Object field names are never refused. A field named marshalJSON keeps its wire
name: encoding/json fixes the spelling of the marshalling method, so the struct
gives the method up and each of its required slice and map fields is spelled with
a generated type that normalizes itself. Those types convert freely to and from
the plain Go type, so handler code is unchanged.
Coming from v6
Go server generation arrives with v7, so there is no generated Go to migrate:
--server go, the Handle…/Call… method namespace, and unknown are all new
surface. Point the generator at a contract you already have and it produces the
Go package described above.
An existing TypeScript project has nothing to do. Every byte of TypeScript output
is what v6 emitted for the same contract — the Go backend reads the same
RpcSchema IR but writes its own files, and --client/--server both default to
typescript, so an invocation that worked on v6 still emits exactly what it did.
The one thing worth knowing before writing a contract for Go: methods reach the
generated API prefixed, so getUser is implemented as HandleGetUser and called
as CallGetUser. That prefix is what buys a clean go vet ./... with no analyzer
excluded, and it is why a method may be named scan, marshalJSON, dispose or
socket without meeting a name Go has already claimed.
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
ServerFunctionsinterface, it generates:- A handler registration method on
rpc.handle.<functionName>(server-side) - A call method on
rpc.server.<functionName>(client-side)
- A handler registration method on
- For each function in your
ClientFunctionsinterface, it generates:- A handler registration method on
rpc.handle.<functionName>(client-side) - A call method on
rpc.client.<functionName>(server-side)
- A handler registration method on
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
Error Handling with RpcError
Important: The generated code automatically handles errors through the RpcError type. You don't need to create wrapper response types with error fields.
Bad: Don't Do This
// WRONG - Don't create wrapper types with error fields
export type UpdateRotationResponse = {
success: boolean;
rotation?: RotationSettings;
error?: string;
};
interface ServerFunctions {
updateRotation: (settings: RotationSettings) => UpdateRotationResponse;
}Good: Use RpcError
// CORRECT - Return the actual data type, errors are handled by RpcError
interface ServerFunctions {
updateRotation: (settings: RotationSettings) => RotationSettings;
}Implementation Example
Server Handler:
import { createRpcServer } from './rpc/server.generated';
import { rpcError } from './rpc/types.generated';
const rpc = createRpcServer(socket);
rpc.handle.updateRotation(async (settings) => {
// Validate settings — throw a typed, branded RpcError to signal failure
if (!settings.interval || settings.interval < 1000) {
throw rpcError('INVALID_INTERVAL', 'Interval must be at least 1000ms', { minInterval: 1000 });
}
// Update rotation settings
const updatedRotation = await db.updateRotation(settings);
// Return success data directly
return updatedRotation;
});Note on error signaling: handlers return the success value or throw. Errors must be thrown (use
throw rpcError(code, message, data?)for a typed one, orthrow new Error(...)), not returned. Every RpcError carries a non-enumerable__rpcErrorbrand soisRpcError()can never confuse a successful result that happens to share the{ message, code }shape with a real error.
Client Usage:
import { createRpcClient } from './rpc/client.generated';
import { isRpcError } from './rpc/types.generated';
const rpc = createRpcClient(socket);
const result = await rpc.server.updateRotation({ interval: 5000, enabled: true });
if (isRpcError(result)) {
// Handle error
console.error(`Error: ${result.message} (code: ${result.code})`);
if (result.data) {
console.error('Additional data:', result.data);
}
} else {
// Handle success - result is typed as RotationSettings
console.log('Rotation updated:', result);
}Benefits of Using RpcError
- Cleaner Types: Your function signatures return actual data types, not wrapper objects
- Built-in Type Guards: Use
isRpcError()to check for errors - Consistent Error Structure: All errors have
code,message, and optionaldata - Type Safety: TypeScript knows the exact type after
isRpcError()check - Error Codes: Attach custom error codes for better error handling
- Additional Context: Include extra data in the
datafield for debugging
Sync vs Async Communication
Synchronous Pattern (Request-Response) Use this pattern when you need to wait for a response:
// define.ts
interface ServerFunctions {
getData: (id: string) => UserData;
}
// client usage
const data = await rpc.server.getData("user-123");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:
// define.ts
interface ServerFunctions {
startStreaming: (topic: string) => void; // Fire-and-forget
}
interface ClientFunctions {
onStreamData: (data: StreamChunk) => void; // Callback for receiving stream data
onStreamEnd: () => void; // Callback when stream ends
}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; // Initiate streaming (fire-and-forget)
stopDataStream: () => void; // Stop streaming
}
interface ClientFunctions {
onStreamChunk: (chunk: StreamChunk) => void; // Receive stream data
onStreamComplete: (totalChunks: number) => void; // Stream finished
onStreamError: (error: string) => void; // Stream error
}2. Server Implementation
import { createRpcServer } from "@socket-rpc/rpc/server.generated";
io.on("connection", (socket) => {
const rpc = createRpcServer(socket);
let streamInterval: NodeJS.Timeout | null = null;
// Handle stream start request
rpc.handle.startDataStream(async (topic) => {
console.log(`Starting stream for topic: ${topic}`);
let chunkId = 0;
const maxChunks = 10;
streamInterval = setInterval(() => {
if (chunkId >= maxChunks) {
clearInterval(streamInterval!);
streamInterval = null;
// Notify client that stream is complete
rpc.client.onStreamComplete(maxChunks);
return;
}
// Send stream chunk to client
rpc.client.onStreamChunk({
id: chunkId++,
content: `Data chunk for ${topic} #${chunkId}`,
timestamp: Date.now()
});
}, 500);
});
// Handle stream stop request
rpc.handle.stopDataStream(async () => {
if (streamInterval) {
clearInterval(streamInterval);
streamInterval = null;
console.log("Stream stopped by client request");
}
});
// Clean up on disconnect
socket.on("disconnect", () => {
if (streamInterval) clearInterval(streamInterval);
rpc.dispose();
});
});3. Client Implementation
import { createRpcClient } from "@socket-rpc/rpc/client.generated";
const socket = io("http://localhost:8080");
const rpc = createRpcClient(socket);
// Set up stream handlers (for calls FROM server)
rpc.handle.onStreamChunk(async (chunk) => {
console.log(`Received chunk ${chunk.id}: ${chunk.content}`);
});
rpc.handle.onStreamComplete(async (totalChunks) => {
console.log(`Stream completed! Received ${totalChunks} chunks total.`);
});
rpc.handle.onStreamError(async (error) => {
console.error("Stream error:", error);
});
socket.on("connect", () => {
// Start streaming data (call TO server)
rpc.server.startDataStream("user-activity");
// Stop stream after 8 seconds
setTimeout(() => {
rpc.server.stopDataStream();
}, 8000);
});
// Cleanup
window.addEventListener("beforeunload", () => rpc.dispose());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.
Using with Zod (AI Framework Compatibility)
Many AI frameworks like Claude Agent SDK use Zod for structured outputs. If you're already using Zod schemas in your AI workflow, you can reuse them with socket-rpc by inferring TypeScript types from your schemas.
Example: Reusing Zod Schemas from AI Framework
// pkg/rpc/define.ts
import { z } from 'zod';
// Your existing Zod schemas (already defined for AI structured outputs)
export const PlanSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string()
});
export const GenerateRequestSchema = z.object({
prompt: z.string(),
maxTokens: z.number().optional()
});
export const GenerateResponseSchema = z.object({
text: z.string(),
usage: z.object({
inputTokens: z.number(),
outputTokens: z.number()
})
});
// Infer TypeScript types from Zod schemas
export type Plan = z.infer<typeof PlanSchema>;
export type GenerateRequest = z.infer<typeof GenerateRequestSchema>;
export type GenerateResponse = z.infer<typeof GenerateResponseSchema>;
// Use inferred types in your interfaces
interface ServerFunctions {
generate: (request: GenerateRequest) => GenerateResponse;
getPlan: (planId: string) => Plan;
}
interface ClientFunctions {
onProgress: (progress: number) => void;
}This approach gives you a single source of truth: your Zod schemas define the structure, and TypeScript types are derived from them. No duplication, full compatibility with both your AI framework and socket-rpc.
