bun-ws-router
Version:
Lightweight client/server WebSocket router for Bun with type-safe Zod/Valibot validation.
271 lines (243 loc) • 8.03 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import type { ZodObject, ZodRawShape, ZodType, z as zType } from "zod";
import { validateMetaSchema } from "../shared/normalize.js";
/**
* Minimal interface for Zod instance to avoid circular type references.
* WARNING: Using `typeof z` directly causes TypeScript declaration generation to fail
* with stack overflow errors. This interface captures only the methods we actually use.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ZodLike {
object: (...args: any[]) => any;
string: (...args: any[]) => any;
number: (...args: any[]) => any;
literal: (...args: any[]) => any;
union: (...args: any[]) => any;
discriminatedUnion: (...args: any[]) => any;
optional: (...args: any[]) => any;
record: (...args: any[]) => any;
any: (...args: any[]) => any;
enum: (...args: any[]) => any;
instanceof: (...args: any[]) => any;
ZodType?: any;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/**
* Type helper utilities for better cross-package type inference
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type BaseMessageShape<T extends string> = {
type: zType.ZodLiteral<T>;
meta: ZodObject<{
timestamp: zType.ZodOptional<zType.ZodNumber>;
correlationId: zType.ZodOptional<zType.ZodString>;
}>;
};
type MessageWithPayloadShape<
T extends string,
P extends ZodType,
> = BaseMessageShape<T> & {
payload: P;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type MessageWithExtendedMetaShape<T extends string, M extends ZodRawShape> = {
type: zType.ZodLiteral<T>;
meta: ZodObject<
{
timestamp: zType.ZodOptional<zType.ZodNumber>;
correlationId: zType.ZodOptional<zType.ZodString>;
} & M
>;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type MessageWithPayloadAndMetaShape<
T extends string,
P extends ZodType,
M extends ZodRawShape,
> = {
type: zType.ZodLiteral<T>;
meta: ZodObject<
{
timestamp: zType.ZodOptional<zType.ZodNumber>;
correlationId: zType.ZodOptional<zType.ZodString>;
} & M
>;
payload: P;
};
/**
* Factory function to create messageSchema using the consumer's Zod instance.
*
* CRITICAL: This factory pattern is required to fix discriminated union support.
* Without it, the library and consumer use different Zod instances, causing
* instanceof checks to fail and discriminatedUnion to throw runtime errors.
*
* The factory pattern ensures:
* - Both library and app use the same Zod instance (no dual package hazard)
* - Discriminated unions work correctly with proper instanceof checks
* - Type inference flows through without manual type assertions
* - Schemas are composable and can be used in unions
*
* @param zod - The Zod instance from the consuming application
* @returns Object with messageSchema function and related utilities
*
* @example Basic usage:
* ```typescript
* import { z } from "zod";
* import { createMessageSchema } from "bun-ws-router/zod";
*
* const { messageSchema } = createMessageSchema(z);
* const PingSchema = messageSchema("PING");
* ```
*
* @example Singleton pattern (recommended for apps):
* ```typescript
* // schemas/factory.ts
* export const { messageSchema, createMessage } = createMessageSchema(z);
*
* // schemas/messages.ts
* import { messageSchema } from "./factory";
* const LoginSchema = messageSchema("LOGIN", { username: z.string() });
* ```
*
* @example With discriminated unions:
* ```typescript
* const PingSchema = messageSchema("PING");
* const PongSchema = messageSchema("PONG");
*
* // This now works correctly!
* const MessageUnion = z.discriminatedUnion("type", [PingSchema, PongSchema]);
* ```
*/
export function createMessageSchema(zod: ZodLike) {
// Create base schemas using the provided Zod instance
const MessageMetadataSchema = zod.object({
timestamp: zod.number().int().positive().optional(),
correlationId: zod.string().optional(),
});
const ErrorCode = zod.enum([
"INVALID_MESSAGE_FORMAT",
"VALIDATION_FAILED",
"UNSUPPORTED_MESSAGE_TYPE",
"AUTHENTICATION_FAILED",
"AUTHORIZATION_FAILED",
"RESOURCE_NOT_FOUND",
"RATE_LIMIT_EXCEEDED",
"INTERNAL_SERVER_ERROR",
]);
/**
* Creates a type-safe WebSocket message schema with simplified overloads
* for better cross-package type compatibility.
*/
function messageSchema<T extends string>(
messageType: T,
): ZodObject<BaseMessageShape<T>>;
function messageSchema<T extends string, P extends ZodObject<ZodRawShape>>(
messageType: T,
payload: P,
): ZodObject<MessageWithPayloadShape<T, P>>;
function messageSchema<T extends string, P extends ZodRawShape>(
messageType: T,
payload: P,
): ZodObject<MessageWithPayloadShape<T, ZodObject<P>>>;
function messageSchema<T extends string, M extends ZodRawShape>(
messageType: T,
payload: undefined,
meta: M,
): ZodObject<MessageWithExtendedMetaShape<T, M>>;
function messageSchema<
T extends string,
P extends ZodObject<ZodRawShape>,
M extends ZodRawShape,
>(
messageType: T,
payload: P,
meta: M,
): ZodObject<MessageWithPayloadAndMetaShape<T, P, M>>;
function messageSchema<
T extends string,
P extends ZodRawShape,
M extends ZodRawShape,
>(
messageType: T,
payload: P,
meta: M,
): ZodObject<MessageWithPayloadAndMetaShape<T, ZodObject<P>, M>>;
function messageSchema<
T extends string,
P extends ZodRawShape | ZodObject<ZodRawShape> | undefined = undefined,
M extends ZodRawShape = Record<string, never>,
>(messageType: T, payload?: P, meta?: M) {
// Validate that extended meta doesn't use reserved keys (fail-fast at schema creation)
validateMetaSchema(meta);
// Meta schema must be strict (reject unknown keys)
const metaSchema = (
meta ? MessageMetadataSchema.extend(meta) : MessageMetadataSchema
).strict();
const baseSchema = {
type: zod.literal(messageType),
meta: metaSchema,
};
if (payload === undefined) {
return zod.object(baseSchema).strict();
}
// Payloads can be a Zod object or a raw shape
const payloadSchema = (
(payload as { _def?: unknown })._def
? (payload as ZodObject<ZodRawShape>)
: zod.object(payload as ZodRawShape)
).strict(); // Payload must also be strict
return zod
.object({
...baseSchema,
payload: payloadSchema,
})
.strict();
}
// Standard schemas used across most WebSocket applications
const ErrorMessage = messageSchema("ERROR", {
code: ErrorCode,
message: zod.string().optional(),
context: zod.record(zod.string(), zod.any()).optional(),
});
// Client-side helper: validates and creates messages for sending
function createMessage<T extends MessageSchemaType>(
schema: T,
payload: T["shape"]["payload"] extends ZodType
? zType.infer<T["shape"]["payload"]>
: undefined,
meta?: Partial<zType.infer<T["shape"]["meta"]>>,
) {
const messageData = {
type: schema.shape.type.value,
...(payload !== undefined && { payload }),
meta: meta || {},
};
return schema.safeParse(messageData);
}
return {
messageSchema,
MessageMetadataSchema,
ErrorCode,
ErrorMessage,
createMessage,
};
}
// Type constraint for schemas created by messageSchema
type MessageSchemaType = ZodObject<{
type: zType.ZodLiteral<string>;
meta: ZodType;
payload?: ZodType;
}>;
// Enhanced type helper for better cross-package inference
export type MessageSchema<T extends string, P extends ZodType = never> = [
P,
] extends [never]
? ZodObject<BaseMessageShape<T>>
: ZodObject<MessageWithPayloadShape<T, P>>;
// Type helper for discriminated unions
export type AnyMessageSchema = ZodObject<{
type: zType.ZodLiteral<string>;
meta: ZodType;
payload?: ZodType;
}>;