defineSocketChannel
Declares a WebSocket channel in server/socket with schemas for each direction, per-room and per-event guards, and handlers for client messages.
Se autoimporta en el código de servidor: server/ y <script server>.
import { defineSocketChannel } from "@meekojs/meeko/server";defineSocketChannel declares a WebSocket channel: the default export of server/socket/<nombre>.ts. It declares, using schemas from shared/, events sent by the client (clientToServer) and server (serverToClient), guards for the connection, each room and each event, and handlers for incoming messages.
The generator emits a public contract with schemas, without importing handlers, for useSocket, the browser's socket registry and the server's socket.<canal>.broadcast. The hub validates Origin, session, channel, room, event and payload on every message.
Usage#
// shared/schemas/chat.ts
import * as v from "valibot";
export const chatInput = v.object({ text: v.pipe(v.string(), v.minLength(1), v.maxLength(500)) });
export const chatMessage = v.object({ text: v.string(), author: v.string() });// server/socket/chat.ts
import { defineSocketChannel } from "@meekojs/meeko/server";
import { chatInput, chatMessage } from "../../shared/schemas/chat";
export default defineSocketChannel({
clientToServer: { message: chatInput },
serverToClient: { message: chatMessage },
auth: "required",
authorize: ({ context, roomId }) => !!context.session.current && roomId === "general",
handlers: {
message: async ({ payload, roomId, context }) => {
await socket.chat.broadcast(roomId ?? "general", "message", { text: payload.text, author: context.session.current!.id });
},
},
});<!-- app/pages/chat.meeko -->
<script lang="ts">
const messages = signal<{ text: string; author: string }[]>([]);
const draft = signal("");
// useSocket without await only exists in the browser: there is no connection in SSR.
const chat = typeof window === "undefined" ? undefined : useSocket("chat", { roomId: "general" });
chat?.on("message", (message) => {
messages.value = [...messages.value, message];
});
async function send(event: SubmitEvent): Promise<void> {
event.preventDefault();
await chat?.emit("message", { text: draft.value });
draft.value = "";
}
</script>
<template>
<ul aria-live="polite"><li each:of="messages.value" each:as="message">{message.author}: {message.text}</li></ul>
<form on:submit="send"><input model="draft" aria-label="Message"><button>Send</button></form>
</template>Type#
function defineSocketChannel<
C extends SchemaMap,
S extends SchemaMap,
TServices = unknown,
>(
definition: SocketChannelDefinition<C, S, TServices>,
): SocketChannelDefinition<C, S, TServices>interface SocketChannelDefinition<
C extends SchemaMap,
S extends SchemaMap,
TServices = unknown,
> {
clientToServer: C;
serverToClient: S;
auth?: "required" | "optional";
authorize?: (args: {
context: RequestContext<TServices>;
roomId?: string;
}) => boolean | Promise<boolean>;
authorizeEvent?: (args: {
event: keyof C & string;
payload: unknown;
context: RequestContext<TServices>;
roomId?: string;
}) => boolean | Promise<boolean>;
authorizeSend?: (args: {
event: keyof S & string;
payload: unknown;
context: RequestContext<TServices>;
roomId?: string;
}) => boolean | Promise<boolean>;
handlers?: {
[K in keyof C]?: (args: {
payload: Output<C[K]>;
context: RequestContext<TServices>;
roomId?: string;
emit: <E extends keyof S & string>(
event: E,
payload: Output<S[E]>,
) => Promise<void>;
}) => void | Promise<void>;
};
}Options#
| Option | Type | Default | Description |
|---|---|---|---|
clientToServer |
{ [evento]: schema } |
required | Events sent by the client, with schemas from shared/. An invalid payload closes the channel. |
serverToClient |
{ [evento]: schema } |
required | Events sent by the server, with schemas from shared/. |
auth |
"required" | "optional" |
"optional" |
"required" rejects connections without a session: the server accepts the upgrade and closes with 4401. |
authorize |
({ context, roomId }) => boolean | Promise<boolean> |
none | Authorizes connection to a room and each delivery; false closes with 4403. |
authorizeEvent |
({ event, payload, context, roomId }) => boolean | Promise<boolean> |
none | Authorizes each incoming event. |
authorizeSend |
({ event, payload, context, roomId }) => boolean | Promise<boolean> |
none | Filters each send to a peer: with false, it does not receive it and remains connected. |
handlers |
{ [evento]: ({ payload, context, roomId, emit }) => void | Promise<void> } |
none | One handler per client event, with the payload already validated. emit replies only to that client. |
Each event can use shorthand ({ message }) when the imported schema has the same name as the event.
Return value#
The same definition, with inferred types. The hub uses it to validate and dispatch; the generator uses it for the channel's public contract.
Examples#
Notify a room from the API#
socket.<canal>.broadcast(roomId, evento, payload), auto-imported on the server, publishes a typed event using the channel contract:
// server/socket/operations.ts
import { defineSocketChannel } from "@meekojs/meeko/server";
import { kitchenChanged, subscribe } from "../../shared/schemas/operations";
export default defineSocketChannel({
clientToServer: { subscribe },
serverToClient: { kitchenChanged },
auth: "required",
});// server/api/orders/[id]/ready.post.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { orderParams, orderSchema } from "../../../../shared/schemas/order";
export default defineApiRoute({
params: orderParams,
response: orderSchema,
auth: "required",
handler: async ({ params }) => {
const order = await markReady(params.id);
await socket.operations.broadcast(order.kitchenId, "kitchenChanged", { orderId: order.id, status: "ready" });
return order;
},
});Rooms per team#
authorize decides who enters each room; authorizeEvent, what they can send:
// server/socket/team.ts
import { defineSocketChannel } from "@meekojs/meeko/server";
import { teamEvent, teamUpdate } from "../../shared/schemas/team";
export default defineSocketChannel({
clientToServer: { teamEvent },
serverToClient: { teamUpdate },
auth: "required",
authorize: async ({ context, roomId }) => !!roomId && (await isMember(context, roomId)),
authorizeEvent: ({ payload }) => typeof payload === "object" && payload !== null,
handlers: {
teamEvent: async ({ payload, roomId }) => {
if (roomId) await socket.team.broadcast(roomId, "teamUpdate", { kind: payload.kind });
},
},
});Common mistakes#
A schema outside shared/#
export default defineSocketChannel({
clientToServer: { message: v.object({ text: v.string() }) },
serverToClient: { message: chatMessage },
});meeko check stops with server\socket\chat.ts: clientToServer.message debe referenciar un schema importado de shared/. A schema with any, unknown or unresolved types reports … debe exponer tipos de entrada y salida de Standard Schema concretos.
import { chatInput, chatMessage } from "../../shared/schemas/chat";
export default defineSocketChannel({ clientToServer: { message: chatInput }, serverToClient: { message: chatMessage } });Emitting a payload that fails the schema#
handlers: { message: async ({ payload, emit }) => emit("message", payload) },With chatMessage requiring author, meeko check reports TS2741: Property 'author' is missing in type '{ text: string; }' but required in type '{ author: string; text: string; }'.
handlers: { message: async ({ payload, emit }) => emit("message", { text: payload.text, author: "sistema" }) },Using roomId without checking it#
The client can connect without a room, so roomId is optional.
authorize: ({ roomId }) => roomId.startsWith("team-"),meeko check reports TS18048: 'roomId' is possibly 'undefined'.
authorize: ({ roomId }) => roomId?.startsWith("team-") ?? false,Notes#
- Security.
Originis checked before the upgrade, usingpublicOriginfromserver/runtime.tsorMEEKO_PUBLIC_ORIGIN.authandauthorizereject with close codes4401and4403, which the client converts to the terminal"unauthorized"state without retrying. An invalid incoming message closes the channel. - Delivery. At most once: the client's
emitrejects if disconnected, without queuing or resending. The hub enforces message size and buffer limits, heartbeat and graceful shutdown. - Multiple instances.
broadcastpublishes through the runtime'sSocketPubSub: in memory with one instance, or withRedisSocketPubSub(or Durable Objects on Workers) with several. - Adapters. Node uses
ws, Bun its native WebSocket, DenoDeno.upgradeWebSocketand Cloudflare Workers a Durable Object with hibernation per room. - SSR. No connection opens on the server.
await useSocket(…)in a component splits setup: the rest runs only in the browser, with the socket already open, and the template cannot use declarations after it (MEEK057), such as the sending handler. For a sending form, useuseSocketwithoutawaitafter checkingwindow, as in the example.