createSocketClient
Low-level Socket client over native WebSocket, with explicit URL and contract, validation in both directions and exponential backoff reconnection.
import { createSocketClient } from "@meekojs/meeko/framework";createSocketClient opens a WebSocket to a Socket channel with an explicit URL and contract. It validates every outgoing and incoming event against the contract schemas, automatically reconnects with exponential backoff and exposes its state as a signal. Delivery is at most once: emit rejects when disconnected, without a queue or resend.
It is not tied to a component: close it with close(). Inside a component, use useSocketClient, which closes it on unmount; in an application, you typically use the auto-import useSocket("chat") or socket.chat, which already know each channel's URL and contract.
Usage#
import { createSocketClient } from "@meekojs/meeko/framework";
import type { Schema } from "@meekojs/meeko/server";
const message: Schema<{ text: string }> = {
"~standard": {
version: 1,
validate: (value) => typeof value === "object" && value !== null && typeof (value as { text?: unknown }).text === "string"
? { value: value as { text: string } }
: { issues: [{ message: "Expected { text }" }] },
},
};
const chatContract = { clientToServer: { message }, serverToClient: { message } };
const chat = createSocketClient("wss://example.com/_meeko/socket/chat?room=general", chatContract, {
onError: (error) => console.warn("socket", error),
});
const stop = chat.on("message", (data) => console.log(data.text));
// Later, with the socket open:
await chat.emit("message", { text: "Hello" });
stop();
chat.close();Type#
function createSocketClient<C extends SocketContract>(
url: string,
contract: C,
options?: SocketClientOptions,
): SocketClient<C>interface SocketClientOptions {
readonly retryMs?: number
readonly maxRetryMs?: number
readonly maxMessageBytes?: number
readonly webSocketFactory?: (url: string) => WebSocket
readonly onError?: (error: unknown) => void
}interface SocketClient<C extends SocketContract> {
readonly state: ReadonlySignal<SocketClientState>;
emit<K extends keyof C["clientToServer"] & string>(
event: K,
data: SchemaInput<C["clientToServer"][K]>,
): Promise<void>;
on<K extends keyof C["serverToClient"] & string>(
event: K,
listener: (data: SchemaOutput<C["serverToClient"][K]>) => void,
): () => void;
close(): void;
}interface SocketContract<
ClientToServer extends Events = Events,
ServerToClient extends Events = Events,
> {
readonly clientToServer: ClientToServer;
readonly serverToClient: ServerToClient;
}Parameters#
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
text | required | Channel ws: or wss: URL: <origen>/_meeko/socket/<canal>, with ?room= if the channel uses rooms. |
contract |
SocketContract |
required | { clientToServer, serverToClient }: each event's schema in each direction. |
options.retryMs |
number | 500 | Initial reconnection delay; doubles on each attempt. |
options.maxRetryMs |
number | 30 000 | Maximum reconnection delay. |
options.maxMessageBytes |
number | 65 536 | Largest incoming message; a larger one (or binary message) is discarded and reported. |
options.webSocketFactory |
(url) => WebSocket |
new WebSocket(url) |
Creates the socket; for tests or runtimes without a global WebSocket. |
options.onError |
(error) => void |
console | Invalid incoming messages and listener failures. |
Return value#
A SocketClient typed by the contract:
| Member | Description |
|---|---|
state |
Read-only signal: "connecting", "open", "closed" or, since 1.1, terminal "unauthorized": the server rejected the session or authorization (close 4401/4403) and the client does not retry. |
emit(event, data) |
Validates and sends. Rejects with Socket is disconnected if not open, Invalid socket event payload if the schema fails, Unknown socket event: … if the event does not exist and Socket message too large if it exceeds the limit. |
on(event, listener) |
Listens to an already validated server event. Returns the function to stop listening. |
close() |
Closes the connection and stops reconnecting. |
The URL is passed unchanged to new WebSocket(url): a URL rejected by the runtime's WebSocket throws its error when creating the client.
Examples#
Wait for it to open before sending#
import { effect } from "@meekojs/meeko";
effect(() => {
if (chat.state.value === "open") void chat.emit("message", { text: "conectado" });
if (chat.state.value === "unauthorized") location.assign("/login");
});state is a signal: an effect (or a component's template) updates on every change.
A test with a fake WebSocket#
import { createSocketClient } from "@meekojs/meeko/framework";
import type { Schema } from "@meekojs/meeko/server";
class FakeSocket extends EventTarget {
readyState: number = WebSocket.CONNECTING;
sent: string[] = [];
send(value: string): void { this.sent.push(value); }
close(): void { this.readyState = WebSocket.CLOSED; this.dispatchEvent(new Event("close")); }
open(): void { this.readyState = WebSocket.OPEN; this.dispatchEvent(new Event("open")); }
}
const text: Schema<string> = { "~standard": { version: 1, validate: (value) => typeof value === "string" ? { value } : { issues: [{ message: "text" }] } } };
const peer = new FakeSocket();
const client = createSocketClient("ws://localhost/chat", { clientToServer: { message: text }, serverToClient: { message: text } }, {
webSocketFactory: () => peer as unknown as WebSocket,
});
await client.emit("message", "early").catch((error: Error) => console.log(error.message)); // Socket is disconnected
peer.open();
await client.emit("message", "hello");
console.log(peer.sent); // [ '{"event":"message","payload":"hello"}' ]
client.close();Outside a Meeko application#
// An admin dashboard listening for store orders
const orders = createSocketClient(`wss://tienda.example.com/_meeko/socket/orders?room=${storeId}`, ordersContract);
orders.on("created", (order) => addRow(order));
window.addEventListener("pagehide", () => orders.close());The server checks Origin: an origin different from the application's must be in the runtime's allowedOrigins.
Common mistakes#
Pass the channel name instead of its URL#
createSocketClient("chat", chatContract);createSocketClient does not know application channels: "chat" is a relative URL. In Node, the global WebSocket throws a DOMException with message TypeError: Invalid URL; with another scheme, such as ftp:, expected a ws: or wss: url.
createSocketClient(`${location.origin.replace(/^http/, "ws")}/_meeko/socket/chat`, chatContract);Inside an application, useSocket("chat") or socket.chat build the URL and know the contract.
Send before it opens#
const chat = createSocketClient(url, chatContract);
await chat.emit("message", { text: "hello" });Rejects with Socket is disconnected: the socket is still "connecting", and the client does not queue messages.
const chat = createSocketClient(url, chatContract);
effect(() => { if (chat.state.value === "open") void chat.emit("message", { text: "hello" }); });Never close it#
function openChat() {
const chat = createSocketClient(url, chatContract);
chat.on("message", render);
}Each call leaves an open socket that also reconnects automatically after disconnecting: connections accumulate until the page reloads.
function openChat() {
const chat = createSocketClient(url, chatContract);
chat.on("message", render);
return () => chat.close();
}Notes#
- Browser only (or with
webSocketFactory). In Node and SSR there is no globalWebSocketto open. - Security. Server messages are validated with
serverToClientbefore reaching listeners: an invalid message is discarded and sent toonError. - Reconnection. After a disconnect, retries after 500 ms, 1 s, 2 s… up to 30 s. Messages sent meanwhile are rejected; resend what you need when it returns to
"open". - Authorization. A
4401or4403close (or the hub's session policy1008) leaves state at"unauthorized"permanently: redirect to sign-in and create a new client afterward.