UNPKG

@ws-kit/zod

Version:

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

174 lines 6.76 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT /** * Runtime envelope builders for message schemas. * Provides two forms: object-oriented (primary) and positional (compact). * * Both return strict Zod 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 = z.object({roomId: z.string()}).transform(...); * const Join = message({ type: "JOIN", payload: JoinPayload, options: {...} }); */ import { DESCRIPTOR, setSchemaOpts, } from "@ws-kit/core/internal"; import { z, } from "zod"; /** * Symbol for Zod payload schema (validator-specific). * Stores the Zod schema for the payload field. */ export const ZOD_PAYLOAD = Symbol.for("@ws-kit/zod-payload"); /** * Standard meta fields that are always allowed. * Users can extend with additional required or optional fields. */ const STANDARD_META_FIELDS = { timestamp: z.number().optional(), correlationId: z.string().optional(), }; /** * 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 = z .object({ ...STANDARD_META_FIELDS, ...(metaDef || {}), }) .strict(); // Detect if value is a Zod schema via duck typing (ZodType is type-only, can't use instanceof). // Checks for parse(), safeParse(), and _def (Zod's internal definition object). const isZodSchema = (x) => !!x && typeof x.parse === "function" && typeof x.safeParse === "function" && !!x._def; // Build payload schema if provided. // If strict option is explicitly false, keep as-is; otherwise apply .strict() const payloadObj = payloadDef ? isZodSchema(payloadDef) ? options?.strict === false ? payloadDef : payloadDef.strict() : z.object(payloadDef).strict() : undefined; // Build root schema: { type, meta, payload? } const rootShape = { type: z.literal(type), meta: metaObj.optional().default({}), ...(payloadObj ? { payload: payloadObj } : {}), }; const root = z.object(rootShape).strict(); // Attach non-enumerable runtime hints for router/plugin. // `messageType` is canonical; `type` is a convenience alias (Zod doesn't conflict). // Valibot only has `messageType` since its schemas use `type` for schema kind. 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, }, [ZOD_PAYLOAD]: { value: payloadObj, 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