UNPKG

@ws-kit/valibot

Version:

Valibot validator adapter for WS-Kit with lightweight runtime validation and minimal bundle size

184 lines 7.3 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT /** * Runtime envelope builders for Valibot schemas. * Provides two forms: object-oriented (primary) and positional (compact). * * Both return strict Valibot root objects with non-enumerable hints for the router * and per-schema options (validateOutgoing, strict) for granular control. * * Users can compose schemas before wrapping to preserve type safety: * const JoinPayload = v.pipe(v.object({roomId: v.string()}), v.transform(...)); * const Join = message({ type: "JOIN", payload: JoinPayload, options: {...} }); * * Mirrors the Zod runtime.ts pattern for parity. */ import { DESCRIPTOR, setSchemaOpts, } from "@ws-kit/core/internal"; import { literal, number, optional, parseAsync, strictObject, string, safeParse as valibot_safeParse, } from "valibot"; /** * Symbol for Valibot payload schema (validator-specific). * Stores the Valibot schema for the payload field. */ export const VALIBOT_PAYLOAD = Symbol.for("@ws-kit/valibot-payload"); /** * Standard meta fields that are always allowed. * Users can extend with additional required or optional fields. */ const STANDARD_META_FIELDS = { timestamp: optional(number()), correlationId: optional(string()), }; /** * Reserved meta field names that cannot be used in extended meta. * These are managed by the router/adapter layer and cannot be overridden in schema definitions. * * @internal */ const RESERVED_META_KEYS = new Set(["clientId", "receivedAt"]); // Implementation export function message(specOrType, payload, metaShape) { // Normalize inputs: support both object and positional forms let type; let payloadDef; let metaDef; let options; if (typeof specOrType === "string") { // Positional form type = specOrType; payloadDef = payload; metaDef = metaShape; } else { // Object form type = specOrType.type; payloadDef = specOrType.payload; metaDef = specOrType.meta; options = specOrType.options; } // Validate that type doesn't use reserved $ws: prefix (system lifecycle events only) if (type.startsWith("$ws:")) { throw new Error(`Message type cannot start with '$ws:' (reserved for system events): "${type}"`); } // Validate that meta doesn't contain reserved keys if (metaDef) { const reservedKeysInMeta = Object.keys(metaDef).filter((key) => RESERVED_META_KEYS.has(key)); if (reservedKeysInMeta.length > 0) { throw new Error(`Reserved meta keys not allowed in schema: ${reservedKeysInMeta.join(", ")}. ` + `Reserved keys: ${Array.from(RESERVED_META_KEYS).join(", ")}`); } } // Build meta schema: standard fields + extended fields const metaObj = strictObject({ ...STANDARD_META_FIELDS, ...(metaDef || {}), }); // Valibot lacks .strict() method, so strictness is set at construction. // Pre-built schemas: used as-is. Raw shapes: wrapped in strictObject(). const isPrebuiltSchema = payloadDef && typeof payloadDef === "object" && "parse" in payloadDef; const payloadObj = payloadDef ? isPrebuiltSchema ? payloadDef // Pre-built schema, use as-is : strictObject(payloadDef) // Raw shape, make strict : undefined; // Build root schema: { type, meta, payload? } const rootShape = { type: literal(type), meta: optional(metaObj, {}), ...(payloadObj ? { payload: payloadObj } : {}), }; const root = strictObject(rootShape); // Attach non-enumerable runtime hints for router/plugin. // Uses `messageType` (not `type`) because Valibot schemas have their own `type` // property indicating schema kind (e.g., "strict_object"). See Zod runtime for parity. Object.defineProperties(root, { messageType: { value: type, enumerable: false }, __runtime: { value: "ws-kit-schema", enumerable: false }, [DESCRIPTOR]: { value: { messageType: type, kind: "event" }, enumerable: false, configurable: true, }, [VALIBOT_PAYLOAD]: { value: payloadObj, enumerable: false }, // Ergonomic parse API: schema.safeParse(data), schema.parse(data) parse: { value: (data) => parseAsync(root, data), enumerable: false, }, safeParse: { value: (data) => { const result = valibot_safeParse(root, data); // Normalize to { data, issues } for Zod API compatibility return { success: result.success, data: result.success ? result.output : undefined, issues: result.issues, }; }, enumerable: false, }, }); // Attach per-schema options if provided if (options) { setSchemaOpts(root, options); } return root; } // Implementation export function rpc(specOrReqType, requestPayload, responseType, responsePayload) { // Normalize inputs: support both object and positional forms let reqType; let reqPayload; let resType; let resPayload; let reqOptions; let resOptions; if (typeof specOrReqType === "string") { // Positional form reqType = specOrReqType; reqPayload = requestPayload; resType = responseType; resPayload = responsePayload; } else { // Object form const spec = specOrReqType; reqType = spec.req.type; reqPayload = spec.req.payload; resType = spec.res.type; resPayload = spec.res.payload; reqOptions = spec.req.options; resOptions = spec.res.options; } // Validate that types don't use reserved $ws: prefix (system lifecycle events only) // Note: message() also validates this, but we check here for clearer error messages if (reqType.startsWith("$ws:")) { throw new Error(`RPC request type cannot start with '$ws:' (reserved for system events): "${reqType}"`); } if (resType.startsWith("$ws:")) { throw new Error(`RPC response type cannot start with '$ws:' (reserved for system events): "${resType}"`); } // Build request schema using message() with per-request options const requestRoot = message({ type: reqType, payload: reqPayload, ...(reqOptions !== undefined ? { options: reqOptions } : {}), }); // Build response schema using message() with per-response options const responseRoot = message({ type: resType, payload: resPayload, ...(resOptions !== undefined ? { options: resOptions } : {}), }); // Attach response to request and override DESCRIPTOR to set kind="rpc" // Note: message() sets kind="event", so we need to replace DESCRIPTOR for RPC Object.defineProperties(requestRoot, { response: { value: responseRoot, enumerable: false, configurable: true }, responseType: { value: resType, enumerable: false }, [DESCRIPTOR]: { value: { messageType: reqType, kind: "rpc" }, enumerable: false, }, }); return requestRoot; } //# sourceMappingURL=runtime.js.map