Socket SDK
The Socket SDK brings the same proxy-based, type-safe SDK pattern from @hyper-fetch/core to real-time sockets.
Instead of HTTP methods ($get, $post), you use $listener and $emitter as leaf keys to create listener and
emitter instances on the fly.
- Type-Safe Sockets: Full TypeScript coverage for topics, responses, and payloads.
- tRPC-like Syntax: Access listeners and emitters with dot-notation (e.g.,
sdk.chat.messages.$listener). - Zero Performance Overhead: Generates instances on-demand using proxies.
- Dynamic Topic Support: Handle parameterized topics like
chat/:roomId. - Centralized Configuration: Configure all listeners/emitters from one place with
$configure.
Initialization
import { Socket, createSocketSdk } from "@hyper-fetch/sockets";
const socket = new Socket({ url: "ws://localhost:3000" });
type MyChatSchema = {
// ... schema definition
};
const sdk = createSocketSdk<typeof socket, MyChatSchema>(socket);
createSocketSdk defaults to camelCaseToKebabCase: true. This means property names in your SDK schema are
automatically converted to kebab-case topic strings (e.g., chatMessages becomes chat-messages). You can disable
this by passing { camelCaseToKebabCase: false } as the third argument.
Defining the Schema
The schema maps your socket topics as a nested type. Leaf nodes use $listener for receiving events and $emitter for
sending events. Dynamic topic segments are prefixed with $ (e.g., $roomId becomes /:roomId).
ListenerModel / EmitterModel for schema leaves — not the *Instance typesWhen defining a schema you are describing one specific topic. Use ListenerModel<{...}> / EmitterModel<{...}> —
omitted fields stay strict (unknown, undefined, string, false) so the type system catches mismatches instead of
silently collapsing to any. The mental model below explains when to reach for each type.
import type { ListenerModel, EmitterModel } from "@hyper-fetch/sockets";
// Schemas describe shape, response, payload and topic only. The socket (and its adapter)
// is injected automatically by createSocketSdk(socket) - no need to repeat per leaf.
type MyChatSchema = {
chat: {
messages: {
$listener: ListenerModel<{
response: { text: string; user: string };
topic: "chat/messages";
}>;
$emitter: EmitterModel<{
payload: { text: string };
topic: "chat/messages";
}>;
};
$roomId: {
$listener: ListenerModel<{
response: { text: string; user: string };
topic: "chat/:roomId";
}>;
};
};
notifications: {
$listener: ListenerModel<{
response: { message: string };
topic: "notifications";
}>;
};
};
*Model vs *Instance — two mindsets
Hyper Fetch ships two complementary type families for describing listeners and emitters. They look similar but
solve opposite problems. Picking the right one is the difference between strict end-to-end safety and accidental
any leaks.
ListenerModel<{...}> / EmitterModel<{...}> | ListenerInstance<{...}> / EmitterInstance<{...}> | |
|---|---|---|
| Mindset | "This specific topic." | "Any listener/emitter that matches this partial shape." |
| Use for | Socket SDK schema leaves, single-topic definitions, listener / emitter factories. | Generic constraints (<T extends ...>), reusable hooks / wrappers / utilities that accept any listener or emitter. |
| Omitted fields default to | Strict, non-any (unknown, undefined, string, false). | any — on purpose, so any concrete Listener / Emitter satisfies the constraint. |
| Why those defaults? | Absence means "there is no payload / no params". The type should reject mismatches. | Absence means "I don't care about that field". The type should accept anything. |
// MODEL — defining the chat/messages topic exactly:
// response is { text, user }
// topic is the literal string
// strict types mean unrelated payloads / topics are rejected.
type ChatMessages = ListenerModel<{
response: { text: string; user: string };
topic: "chat/messages";
}>;
// INSTANCE — accepting any listener whose response has a `text` field:
// response is constrained
// topic, params etc. can be ANY shape — caller picks.
function MessageBubble<T extends ListenerInstance<{ response: { text: string } }>>(props: { listener: T }) {
// ...
}
If you find yourself writing ListenerInstance inside a schema, that is a signal you wanted ListenerModel. If you
find yourself writing ListenerModel as a generic constraint on a reusable component, that is a signal you wanted
ListenerInstance.
socket per leafYou do not have to add socket: AppSocket to every ListenerModel / EmitterModel declaration.
createSocketSdk(socket) rewrites the schema type at the boundary so every Listener and Emitter leaf carries the
actual socket (and therefore its adapter). The schema describes topic shape; the SDK supplies the runtime context.
Usage
Listening to events
sdk.chat.messages.$listener.listen(({ data }) => {
console.log(data.text, data.user);
});
Emitting events
sdk.chat.messages.$emitter.emit({ payload: { text: "Hello!" } });
Dynamic topics with parameters
sdk.chat.$roomId.$listener
.setParams({ roomId: "general" })
.listen(({ data }) => {
console.log(data);
});
SDK Configuration ($configure)
The $configure method works the same way as the core HTTP SDK. Configuration supports two value formats:
plain objects for common settings and functions for full access to every Listener/Emitter setter.
Instance-Specific Keys (Dot-Path)
Use dot-path keys that mirror the SDK accessor chain to target a specific listener or emitter:
const configured = sdk.$configure({
"chat.messages.$listener": (instance) => instance.setOptions({ buffer: true }),
"chat.messages.$emitter": (instance) => instance.setPayloadMapper(myMapper),
});
Topic Group Keys
Use topic-based keys to configure all listeners and emitters on a topic at once:
const configured = sdk.$configure({
"chat/messages": { options: { priority: "high" } },
"chat/*": { options: { reconnect: true } },
});
Function-Based Values
Functions receive the instance and return a modified one, giving access to every setter:
import type { SocketSdkConfigurationValue } from "@hyper-fetch/sockets";
const setReconnect: SocketSdkConfigurationValue = (instance) =>
instance.setOptions({ reconnect: true });
const configured = sdk.$configure({
"*": setReconnect,
"chat.messages.$emitter": (instance) => instance.setPayloadMapper(myMapper),
});
Plain Object Values
For common settings, use a plain object shorthand:
const configured = sdk.$configure({
"*": { options: { reconnect: true } },
"chat/*": { options: { priority: "high" } },
});
| Key | Type | Description |
|---|---|---|
options | Record<string, unknown> | Adapter-specific options passed to setOptions |
Application Order
When multiple keys match, they apply in this order (later overrides earlier):
"*"— Global defaults- Topic groups —
"chat/messages","chat/*" - Dot-path specific —
"chat.messages.$listener"
Configuration Keys
"*"— Matches all listeners and emitters (global defaults).- Exact topic — e.g.
"chat/messages"matches all listeners/emitters on that topic. - Wildcard — e.g.
"chat/*"matches topics likechat/messages,chat/rooms, etc. - Dot-path — e.g.
"chat.messages.$listener"targets a specific instance.
Multi-file Configuration
import { createSocketConfiguration } from "@hyper-fetch/sockets";
import type { MyChatSchema } from "./schema";
export const chatConfig = createSocketConfiguration<MyChatSchema>()({
"chat/*": { options: { reconnect: true } },
"chat.messages.$listener": (instance) => instance.setOptions({ buffer: true }),
});
The Socket SDK provides the same developer experience as the core HTTP SDK, adapted for real-time communication.
Define your schema once, access listeners and emitters via dot-notation, and centralize configuration with $configure.
Configuration supports plain-object shorthand, function-based values for full setter access, topic group matching with
wildcards, and a clear 3-level application order (global -> topic group -> dot-path specific).
