npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

socketrpc-gen

v8.0.0

Published

Code generator for Socket.IO RPC packages using ts-morph.

Downloads

115

Readme

Socket RPC

npm version

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.io setup.
  • 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-call return-or-throw error mode.
  • Connection-Aware: connected state plus onConnect / onDisconnect / onReconnect hooks for re-syncing after reconnects.
  • Cancellable Calls: Per-call AbortSignal and timeout, plus volatile to 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:

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.ts

This 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 GenClient

Server

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 uses console.error.
  • -e, --error-mode <mode>: How call methods surface failures — return the RpcError (default, check with isRpcError) or throw it (use try/catch).
  • -c, --client <language>: Language of the generated client. (Default: "typescript")
  • -s, --server <language>: Language of the generated server — typescript or go. (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 · removeListener

The 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 offline

Timeouts 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/go

That 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:

  • ctx as a parameter — it is the context parameter of the generated ServerHandler and Client signatures.
  • 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 ServerHandler or ClientOptions.

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 ServerFunctions interface, it generates:
    • A handler registration method on rpc.handle.<functionName> (server-side)
    • A call method on rpc.server.<functionName> (client-side)
  • For each function in your ClientFunctions interface, it generates:
    • A handler registration method on rpc.handle.<functionName> (client-side)
    • A call method on rpc.client.<functionName> (server-side)

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, or throw new Error(...)), not returned. Every RpcError carries a non-enumerable __rpcError brand so isRpcError() 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 optional data
  • 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 data field 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.