defineSocketChannel
Declara un canal WebSocket de server/socket con los schemas de cada dirección, sus guardas por sala y evento y los handlers de los mensajes del cliente.
Se autoimporta en el código de servidor: server/ y <script server>.
import { defineSocketChannel } from "@meekojs/meeko/server";defineSocketChannel declara un canal WebSocket: el export por defecto de server/socket/<nombre>.ts. Declara con schemas de shared/ los eventos que envía el cliente (clientToServer) y los que envía el servidor (serverToClient), las guardas de la conexión, de cada sala y de cada evento, y los handlers de los mensajes que llegan.
El generador emite un contrato público con los schemas, sin importar los handlers, para useSocket, el registro socket del navegador y socket.<canal>.broadcast del servidor. El hub valida Origin, sesión, canal, sala, evento y payload en cada mensaje.
Uso#
// 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 sin await sólo existe en el navegador: en SSR no hay conexión.
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="Mensaje"><button>Enviar</button></form>
</template>Tipo#
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>;
};
}Opciones#
| Opción | Tipo | Por defecto | Descripción |
|---|---|---|---|
clientToServer |
{ [evento]: schema } |
obligatorio | Los eventos que envía el cliente, con schemas de shared/. Un payload inválido cierra el canal. |
serverToClient |
{ [evento]: schema } |
obligatorio | Los eventos que envía el servidor, con schemas de shared/. |
auth |
"required" | "optional" |
"optional" |
"required" rechaza la conexión sin sesión: el servidor acepta el upgrade y cierra con 4401. |
authorize |
({ context, roomId }) => boolean | Promise<boolean> |
ninguno | Autoriza la conexión a una sala y cada entrega; false cierra con 4403. |
authorizeEvent |
({ event, payload, context, roomId }) => boolean | Promise<boolean> |
ninguno | Autoriza cada evento entrante. |
authorizeSend |
({ event, payload, context, roomId }) => boolean | Promise<boolean> |
ninguno | Filtra cada envío a un peer: con false no lo recibe y sigue conectado. |
handlers |
{ [evento]: ({ payload, context, roomId, emit }) => void | Promise<void> } |
ninguno | Un handler por evento del cliente, con el payload ya validado. emit responde sólo a ese cliente. |
Cada evento puede escribirse en forma abreviada ({ message }) cuando el schema importado se llama como el evento.
Valor devuelto#
La misma definición, con sus tipos inferidos. El hub la usa para validar y despachar; el generador, para el contrato público del canal.
Ejemplos#
Avisar a una sala desde la API#
socket.<canal>.broadcast(roomId, evento, payload), autoimportado en el servidor, publica un evento tipado con el contrato del canal:
// 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;
},
});Salas por equipo#
authorize decide quién entra en cada sala; authorizeEvent, qué puede enviar:
// 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 });
},
},
});Errores comunes#
Un schema fuera de shared/#
export default defineSocketChannel({
clientToServer: { message: v.object({ text: v.string() }) },
serverToClient: { message: chatMessage },
});meeko check se detiene con server\socket\chat.ts: clientToServer.message debe referenciar un schema importado de shared/. Un schema con any, unknown o tipos sin resolver da … 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 } });Emitir un payload que no cumple el schema#
handlers: { message: async ({ payload, emit }) => emit("message", payload) },Con chatMessage exigiendo author, meeko check da 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" }) },Usar roomId sin comprobarlo#
El cliente puede conectarse sin sala, así que roomId es opcional.
authorize: ({ roomId }) => roomId.startsWith("team-"),meeko check da TS18048: 'roomId' is possibly 'undefined'.
authorize: ({ roomId }) => roomId?.startsWith("team-") ?? false,Notas#
- Seguridad. El
Originse comprueba antes del upgrade, conpublicOrigindeserver/runtime.tsoMEEKO_PUBLIC_ORIGIN.authyauthorizerechazan con los cierres4401y4403, que el cliente convierte en el estado terminal"unauthorized"sin reintentar. Un mensaje entrante inválido cierra el canal. - Entrega. A lo sumo una vez:
emitdel cliente rechaza si está desconectado, sin cola ni reenvío. El hub aplica límites de tamaño de mensaje y de buffer, heartbeat y cierre ordenado. - Varias instancias.
broadcastpublica en elSocketPubSubdel runtime: en memoria con una instancia, o conRedisSocketPubSub(o Durable Objects en Workers) con varias. - Adaptadores. Node usa
ws, Bun su WebSocket nativo, DenoDeno.upgradeWebSockety Cloudflare Workers un Durable Object con hibernación por sala. - SSR. No se abre ninguna conexión en el servidor.
await useSocket(…)en el componente divide el setup: el resto se ejecuta sólo en el navegador, con el socket ya abierto, y la plantilla no puede usar lo que se declara después (MEEK057), como el manejador que envía. Para un formulario de envío, usauseSocketsinawaittras comprobarwindow, como en el ejemplo.