@ws-kit/valibot
v0.10.1
Published
Valibot validator adapter for WS-Kit with lightweight runtime validation and minimal bundle size
Downloads
664
Readme
@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
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 schemasrpc(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, orneverif undefinedInferMeta<T>— Extract extended meta fields (excluding reserved keys)InferMessage<T>— Full message type (equivalent toInferOutput<T>)InferResponse<T>— Extract response type from an RPC schema, orneverif undefined
Example:
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 instancecreateRouter— Core router factory (from@ws-kit/core). Available from both@ws-kit/coreand@ws-kit/valibotfor flexibility — choose whichever import source you prefer
Import patterns (both valid):
// ✅ 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:
// 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:
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:
// ✅ 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 vFull Type Inference
Schemas and payloads flow through handlers with complete type safety:
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:
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:
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:
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:
// 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 routervalibot(peer) — Validation library@ws-kit/bun(optional) — Bun platform adapter withserve()helper@ws-kit/cloudflare(optional) — Cloudflare Durable Objects adapter@ws-kit/client(optional) — Type-safe browser client
