@ws-kit/valibot
Version:
Valibot validator adapter for WS-Kit with lightweight runtime validation and minimal bundle size
184 lines • 7.3 kB
JavaScript
// 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