UNPKG

@ws-kit/valibot

Version:

Valibot validator adapter for WS-Kit with lightweight runtime validation and minimal bundle size

300 lines (225 loc) 7.81 kB
# @ws-kit/valibot **Valibot validator adapter for type-safe WebSocket routing with ws-kit.** Adds validation capability and RPC support to the core router via the `withValibot()` plugin. ## Quick Start ```typescript import { v, message, rpc, withValibot, createRouter } from "@ws-kit/valibot"; import { serve } from "@ws-kit/bun"; // Define message schemas with type-safe payload inference const Join = message("JOIN", { roomId: v.string() }); const GetUser = rpc("GET_USER", { id: v.string() }, "USER", { id: v.string(), name: v.string(), }); // Create router and add validation type AppData = { userId?: string }; const router = createRouter<AppData>() .plugin(withValibot()) .on(Join, (ctx) => { // ctx.payload: { roomId: string } (validated) ctx.send(Join, { roomId: "42" }); }) .rpc(GetUser, async (ctx) => { // ctx.payload: { id: string } (validated) ctx.reply({ id: ctx.payload.id, name: "Alice" }); }); serve(router, { port: 3000 }); ``` ## What This Package Exports ### Schema Builders - **`message(type, payload?)`** — Create event message schemas - **`rpc(requestType, requestPayload, responseType, responsePayload)`** — Create RPC schemas ### Plugin - **`withValibot()`** — Validation plugin that adds payload validation and RPC support ### Type Inference Extract individual components from schemas with zero runtime cost: - **`InferType<T>`** — Extract message type literal (e.g., `"JOIN"`) - **`InferPayload<T>`** — Extract payload shape, or `never` if undefined - **`InferMeta<T>`** — Extract extended meta fields (excluding reserved keys) - **`InferMessage<T>`** — Full message type (equivalent to `InferOutput<T>`) - **`InferResponse<T>`** — Extract response type from an RPC schema, or `never` if undefined **Example**: ```typescript import { v, message } from "@ws-kit/valibot"; import type { InferType, InferPayload, InferMeta, InferResponse, } from "@ws-kit/valibot"; const Join = message("JOIN", { roomId: v.string() }); const GetUser = message( "GET_USER", { id: v.string() }, { response: { name: v.string() } }, ); type JoinType = InferType<typeof Join>; // "JOIN" type JoinPayload = InferPayload<typeof Join>; // { roomId: string } type GetUserResponse = InferResponse<typeof GetUser>; // { name: string } ``` ### Re-exports - **`v`** — Canonical Valibot instance - **`createRouter`** — Core router factory (from `@ws-kit/core`). Available from both `@ws-kit/core` and `@ws-kit/valibot` for flexibility — choose whichever import source you prefer **Import patterns (both valid)**: ```typescript // ✅ Single import source (recommended) import { createRouter, v, message, withValibot } from "@ws-kit/valibot"; // ✅ Also works import { createRouter } from "@ws-kit/core"; import { v, message, withValibot } from "@ws-kit/valibot"; ``` ## Key Design Principles ### Plugin-Based Architecture Validation is added via the `withValibot()` plugin, not baked into the core: ```typescript // Tiny router without validation const router = createRouter(); // Add validation plugin for full capability const validated = router.plugin(withValibot()); // Now you have ctx.payload, ctx.send(), ctx.reply(), etc. ``` ### Capability Gating Methods only exist when enabled: ```typescript const router = createRouter(); // ❌ Type error: rpc() doesn't exist yet router.rpc(schema, handler); const router2 = createRouter().plugin(withValibot()); // ✅ OK: rpc() is available after plugin router2.rpc(schema, handler); ``` ### Single Canonical Import Source All validator and helper imports come from one place to prevent dual-package hazards: ```typescript // ✅ CORRECT: Single import source import { v, message, rpc, withValibot, createRouter } from "@ws-kit/valibot"; // ❌ AVOID: Dual imports (creates type mismatches) import * as v from "valibot"; // Different instance import { message } from "@ws-kit/valibot"; // Uses @ws-kit/valibot's v ``` ### Full Type Inference Schemas and payloads flow through handlers with complete type safety: ```typescript const Join = message("JOIN", { roomId: v.string() }); router.on(Join, (ctx) => { ctx.payload; // ✅ { roomId: string } (inferred) ctx.type; // ✅ "JOIN" (literal) ctx.send; // ✅ Available in event handlers }); const GetUser = rpc("GET_USER", { id: v.string() }, "USER", { id: v.string(), name: v.string(), }); router.rpc(GetUser, async (ctx) => { ctx.payload; // ✅ { id: string } (inferred) ctx.reply; // ✅ Available in RPC handlers ctx.progress; // ✅ For streaming updates }); ``` ## Real Valibot Schemas with Strict Validation Schemas returned by `message()` and `rpc()` are real Valibot schemas, enabling full Valibot capabilities: ```typescript const Join = message("JOIN", { roomId: v.string() }); // Use schemas for client-side validation before sending const clientMsg = { type: "JOIN" as const, meta: {}, payload: { roomId: "42" }, }; const result = Join.safeParse(clientMsg); if (!result.success) { console.error("Invalid message:", result.error); } else { sendToServer(result.data); } ``` ### Strict Validation by Default All schemas enforce **strict mode**, rejecting unknown keys at every level: ```typescript const TestMsg = message("TEST", { id: v.number() }); // ✅ Valid: correct structure TestMsg.safeParse({ type: "TEST", meta: {}, payload: { id: 123 }, }); // ❌ Invalid: unknown root key TestMsg.safeParse({ type: "TEST", meta: {}, payload: { id: 123 }, extra: "not allowed", // Unknown key rejected }); // ❌ Invalid: unknown payload key TestMsg.safeParse({ type: "TEST", meta: {}, payload: { id: 123, extra: "not allowed" }, // Unknown key rejected }); ``` ### Extended Meta Fields Meta can be extended with application-specific fields: ```typescript const WithMeta = message( "TEST", { data: v.string() }, { roomId: v.string(), priority: v.optional(v.number()) }, ); // ✅ Valid: required and optional extended fields WithMeta.safeParse({ type: "TEST", meta: { roomId: "room-1", priority: 5 }, payload: { data: "hello" }, }); // ✅ Also valid: optional field omitted WithMeta.safeParse({ type: "TEST", meta: { roomId: "room-1" }, payload: { data: "hello" }, }); ``` ### Composable with Valibot Ecosystem Since schemas are real Valibot schemas, you can use all Valibot features: ```typescript // Discriminated unions over message types const MessageSchema = v.union([ message("JOIN", { roomId: v.string() }), message("LEAVE", { reason: v.string() }), message("PING"), ]); const result = MessageSchema.safeParse(incomingMsg); // Type narrowing works: result.data.type is "JOIN" | "LEAVE" | "PING" // Transformations and pipes const ValidatedJoin = v.pipe( message("JOIN", { roomId: v.string() }), v.transform((msg) => ({ ...msg, meta: { ...msg.meta, timestamp: Date.now() }, })), ); // RPC response validation const GetUser = rpc("GET_USER", { id: v.string() }, "USER", { id: v.string(), name: v.string(), }); // Validate response independently const response = { type: "USER", meta: {}, payload: { id: "1", name: "Alice" }, }; if (GetUser.response.safeParse(response).success) { console.log("Response is valid"); } ``` ## Platform Support This adapter works with any ws-kit platform: - **`@ws-kit/bun`** — Bun WebSocket server (recommended) - **`@ws-kit/cloudflare`** — Cloudflare Durable Objects - Custom platforms via `@ws-kit/core` ## Dependencies - **`@ws-kit/core`** (required) — Core router - **`valibot`** (peer) — Validation library - **`@ws-kit/bun`** (optional) — Bun platform adapter with `serve()` helper - **`@ws-kit/cloudflare`** (optional) — Cloudflare Durable Objects adapter - **`@ws-kit/client`** (optional) — Type-safe browser client