@ws-kit/zod
Version:
Zod validator adapter for WS-Kit with runtime schema validation and full TypeScript inference
174 lines • 6.76 kB
JavaScript
// 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