JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 3306
  • Score
    100M100P100Q112731F
  • License MIT

Zod validator adapter for WS-Kit with runtime schema validation and full TypeScript inference

Package Exports

  • @ws-kit/zod

Readme

@ws-kit/zod

Zod validator adapter for type-safe WebSocket routing with ws-kit.

Adds validation capability and RPC support to the core router via the withZod() plugin.

Quick Start

import { createRouter, z, message, rpc, withZod } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

// Define message schemas with type-safe payload inference
const Join = message("JOIN", { roomId: z.string() });
const GetUser = rpc("GET_USER", { id: z.string() }, "USER", {
  id: z.string(),
  name: z.string(),
});

interface Connection {
  userId?: string;
}

const router = createRouter<Connection>()
  .plugin(withZod())
  .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

  • withZod() — 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 z.infer<T>)
  • InferResponse<T> — Extract response type from an RPC schema, or never if undefined

Example:

import { z, message } from "@ws-kit/zod";
import type {
  InferType,
  InferPayload,
  InferMeta,
  InferResponse,
} from "@ws-kit/zod";

const Join = message("JOIN", { roomId: z.string() });
const GetUser = rpc("GET_USER", { id: z.string() }, "USER", {
  id: z.string(),
  name: z.string(),
});

type JoinType = InferType<typeof Join>; // "JOIN"
type JoinPayload = InferPayload<typeof Join>; // { roomId: string }
type GetUserResponse = InferResponse<typeof GetUser>; // { id: string; name: string }

Re-exports

  • z — Canonical Zod instance
  • createRouter — Core router factory (from @ws-kit/core). Available from both @ws-kit/core and @ws-kit/zod for flexibility — choose whichever import source you prefer

Import patterns (both valid):

// ✅ Single import source (recommended)
import { createRouter, z, message, withZod } from "@ws-kit/zod";

// ✅ Also works
import { createRouter } from "@ws-kit/core";
import { z, message, withZod } from "@ws-kit/zod";

Key Design Principles

Plugin-Based Architecture

Validation is added via the withZod() plugin, not baked into the core:

// Tiny router without validation
const router = createRouter();

// Add validation plugin for full capability
const validated = router.plugin(withZod());

// 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(withZod());

// ✅ 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 { z, message, rpc, withZod, createRouter } from "@ws-kit/zod";

// ❌ AVOID: Dual imports (creates type mismatches)
import { z } from "zod"; // Different instance
import { message } from "@ws-kit/zod"; // Uses @ws-kit/zod's z

Full Type Inference

Schemas and payloads flow through handlers with complete type safety:

const Join = message("JOIN", { roomId: z.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: z.string() }, "USER", {
  id: z.string(),
  name: z.string(),
});

router.rpc(GetUser, async (ctx) => {
  ctx.payload; // ✅ { id: string } (inferred)
  ctx.reply; // ✅ Available in RPC handlers
  ctx.progress; // ✅ For streaming updates
});

Real Zod Schemas with Strict Validation

Schemas returned by message() and rpc() are real Zod objects, enabling full Zod capabilities:

const Join = message("JOIN", { roomId: z.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: z.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: z.string() },
  { roomId: z.string(), priority: z.number().optional() },
);

// ✅ 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 Zod Ecosystem

Since schemas are real Zod objects, you can use all Zod features:

// Discriminated unions over message types
const MessageSchema = z.discriminatedUnion("type", [
  message("JOIN", { roomId: z.string() }),
  message("LEAVE", { reason: z.string() }),
  message("PING"),
]);

const result = MessageSchema.safeParse(incomingMsg);
// Type narrowing works: result.data.type is "JOIN" | "LEAVE" | "PING"

// Transformations and refinements
const ValidatedJoin = message("JOIN", { roomId: z.string() }).transform(
  (msg) => ({
    ...msg,
    meta: { ...msg.meta, timestamp: Date.now() },
  }),
);

// RPC response validation
const GetUser = rpc("GET_USER", { id: z.string() }, "USER", {
  id: z.string(),
  name: z.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
  • zod (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