bun-ws-router
Version:
A simple and efficient WebSocket router for Bun with Zod/Valibot message validation.
207 lines (189 loc) • 5.1 kB
text/typescript
/* SPDX-FileCopyrightText: 2025-present Kriasoft */
/* SPDX-License-Identifier: MIT */
import * as v from "valibot";
import type { InferOutput, ObjectSchema } from "valibot";
/**
* Base schema for message metadata.
* Provides common fields that are available on all messages.
* Can be extended for specific message types.
*/
export const MessageMetadataSchema = v.object({
clientId: v.optional(v.string()),
timestamp: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
correlationId: v.optional(v.string()),
});
/**
* Base message schema that all specific message types extend.
* Defines the minimum structure required for routing.
*/
export const MessageSchema = v.object({
type: v.string(),
meta: MessageMetadataSchema,
});
/**
* Standard error codes for WebSocket communication.
* Used in ErrorMessage payloads for consistent error handling.
*/
export const ErrorCode = v.picklist([
"INVALID_MESSAGE_FORMAT",
"VALIDATION_FAILED",
"UNSUPPORTED_MESSAGE_TYPE",
"AUTHENTICATION_FAILED",
"AUTHORIZATION_FAILED",
"RESOURCE_NOT_FOUND",
"RATE_LIMIT_EXCEEDED",
"INTERNAL_SERVER_ERROR",
]);
export type ErrorCode = InferOutput<typeof ErrorCode>;
/**
* Creates a type-safe WebSocket message schema with Valibot.
*
* The schema includes:
* - A literal type field for routing messages
* - Metadata for tracking client info and message context
* - Optional payload for the message data
*
* Types are fully inferred for use with WebSocketRouter handlers.
*/
export function messageSchema<T extends string>(
messageType: T,
): ObjectSchema<
{
type: v.LiteralSchema<T, T>;
meta: typeof MessageMetadataSchema;
},
undefined
>;
export function messageSchema<
T extends string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
P extends v.BaseSchema<any, any, any>,
>(
messageType: T,
payload: P,
): ObjectSchema<
{
type: v.LiteralSchema<T, T>;
meta: typeof MessageMetadataSchema;
payload: P;
},
undefined
>;
export function messageSchema<
T extends string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
M extends ObjectSchema<any, any>,
>(
messageType: T,
payload: undefined,
meta: M,
): ObjectSchema<
{
type: v.LiteralSchema<T, T>;
meta: ObjectSchema<
typeof MessageMetadataSchema.entries & M["entries"],
undefined
>;
},
undefined
>;
export function messageSchema<
T extends string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
P extends v.BaseSchema<any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
M extends ObjectSchema<any, any>,
>(
messageType: T,
payload: P,
meta: M,
): ObjectSchema<
{
type: v.LiteralSchema<T, T>;
meta: ObjectSchema<
typeof MessageMetadataSchema.entries & M["entries"],
undefined
>;
payload: P;
},
undefined
>;
export function messageSchema<
T extends string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
P extends v.BaseSchema<any, any, any> | undefined = undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
M extends ObjectSchema<any, any> | undefined = undefined,
>(
messageType: T,
payload?: P,
meta?: M,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ObjectSchema<any, any> {
const metaSchema = meta
? v.object({ ...MessageMetadataSchema.entries, ...meta.entries })
: MessageMetadataSchema;
const baseSchema = {
type: v.literal(messageType),
meta: metaSchema,
};
if (payload === undefined) {
return v.object(baseSchema);
}
return v.object({
...baseSchema,
payload,
});
}
/**
* Standard error message schema for consistent error responses.
*/
export const ErrorMessage = messageSchema(
"ERROR",
v.object({
code: ErrorCode,
message: v.optional(v.string()),
context: v.optional(v.record(v.string(), v.any())),
}),
);
/**
* Creates a validated WebSocket message from a schema.
*
* @example
* ```typescript
* const EchoSchema = messageSchema("ECHO", v.object({ text: v.string() }));
* const message = createMessage(EchoSchema, { text: "Hello" });
*
* if (message.success) {
* ws.send(JSON.stringify(message.output));
* }
* ```
*/
export function createMessage<T extends MessageSchemaType>(
schema: T,
payload: T["entries"]["payload"] extends v.BaseSchema<
unknown,
unknown,
v.BaseIssue<unknown>
>
? InferOutput<T["entries"]["payload"]>
: undefined,
meta?: Partial<InferOutput<T["entries"]["meta"]>>,
) {
const messageData = {
type: schema.entries.type.literal,
payload,
meta: meta || {},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return v.safeParse(schema as any, messageData);
}
// Helper type for the schema type returned by messageSchema
type MessageSchemaType = ObjectSchema<
{
type: v.LiteralSchema<string, string>;
meta: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
payload?: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
},
undefined
>;