bun-ws-router
Version:
Lightweight client/server WebSocket router for Bun with type-safe Zod/Valibot validation.
112 lines • 4.22 kB
JavaScript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import { validateMetaSchema } from "../shared/normalize.js";
/**
* Factory function to create messageSchema using the consumer's Valibot instance.
*
* CRITICAL: This factory pattern is required to fix discriminated union support.
* Without it, the library and consumer use different Valibot instances, causing
* instanceof checks to fail and discriminatedUnion to throw runtime errors.
*
* The factory pattern ensures:
* - Both library and app use the same Valibot instance (no dual package hazard)
* - Validation works correctly with proper instanceof checks
* - Type inference flows through without manual type assertions
* - Schemas are composable and can be used in unions
*
* @param valibot - The Valibot instance from the consuming application
* @returns Object with messageSchema function and related utilities
*
* @example Basic usage:
* ```typescript
* import * as v from "valibot";
* import { createMessageSchema } from "bun-ws-router/valibot";
*
* const { messageSchema } = createMessageSchema(v);
* const PingSchema = messageSchema("PING");
* ```
*
* @example Singleton pattern (recommended for apps):
* ```typescript
* // schemas/factory.ts
* export const { messageSchema, createMessage } = createMessageSchema(v);
*
* // schemas/messages.ts
* import { messageSchema } from "./factory";
* const LoginSchema = messageSchema("LOGIN", { username: v.string() });
* ```
*
* @example With discriminated unions:
* ```typescript
* const PingSchema = messageSchema("PING");
* const PongSchema = messageSchema("PONG");
*
* // This now works correctly!
* const MessageUnion = v.union([PingSchema, PongSchema]);
* ```
*/
export function createMessageSchema(valibot) {
// Create base schemas using the provided Valibot instance
const MessageMetadataSchema = valibot.strictObject({
timestamp: valibot.optional(valibot.pipe(valibot.number(), valibot.integer(), valibot.minValue(1))),
correlationId: valibot.optional(valibot.string()),
});
const ErrorCode = valibot.picklist([
"INVALID_MESSAGE_FORMAT",
"VALIDATION_FAILED",
"UNSUPPORTED_MESSAGE_TYPE",
"AUTHENTICATION_FAILED",
"AUTHORIZATION_FAILED",
"RESOURCE_NOT_FOUND",
"RATE_LIMIT_EXCEEDED",
"INTERNAL_SERVER_ERROR",
]);
function messageSchema(messageType, payload, meta) {
// Validate that extended meta doesn't use reserved keys (fail-fast at schema creation)
validateMetaSchema(meta);
const metaSchema = meta
? valibot.strictObject({ ...MessageMetadataSchema.entries, ...meta })
: MessageMetadataSchema;
const baseSchema = {
type: valibot.literal(messageType),
meta: metaSchema,
};
if (payload === undefined) {
return valibot.strictObject(baseSchema);
}
// Payloads can be a Valibot object or a raw shape
const payloadSchema =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload.kind === "object"
? payload
: valibot.strictObject(payload);
return valibot.strictObject({
...baseSchema,
payload: payloadSchema,
});
}
// Standard schemas used across most WebSocket applications
const ErrorMessage = messageSchema("ERROR", {
code: ErrorCode,
message: valibot.optional(valibot.string()),
context: valibot.optional(valibot.record(valibot.string(), valibot.any())),
});
// Client-side helper: validates and creates messages for sending
function createMessage(schema, payload, meta) {
const messageData = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: schema.entries.type.literal,
...(payload !== undefined && { payload }),
meta: meta || {},
};
return valibot.safeParse(schema, messageData);
}
return {
messageSchema,
MessageMetadataSchema,
ErrorCode,
ErrorMessage,
createMessage,
};
}
//# sourceMappingURL=schema.js.map