valita-server
v1.2.4
Published
express-like api framework with built-in zod schema validation
Maintainers
Readme
Valita
Valita is a minimal, composable HTTP toolkit for Node.js and AWS Lambda. It gives you an Express-like routing API with first-class Zod validation, predictable request/response shapes.
Features
- Routing primitives — Register
get,post,put,del, andoptionsroutes with familiar path patterns and Express-style params (/books/:id). - OPTIONS routes —
optionsregisters anOPTIONSroute for one path. SetoptionsHandleroncreateServerorcreateLambdato answerOPTIONSrequests when nooptionsroute matches. - Middleware pipeline — Chain any number of middleware functions before your controller. Each middleware can short-circuit by returning a response or continue by returning
undefined. - Schema validation — Attach Zod schemas to
params,query,body,headers, andcookies. Requests are validated automatically and fail with consistent400responses. - File uploads —
multipart/form-datais parsed in the Node and Lambda adapters. Text fields stay onrequest.body. Files are onrequest.files. - Server + Lambda adapters — Use
createServerfor Node’shttpmodule orcreateLambdafor AWS Lambda/API Gateway — same routes, same handlers. - Typed contracts —
Request,Response,Schema,ControllerFn,MiddlewareFn, andUploadedFiletypes ship with the library so your editor sees the same shapes Valita expects.
Installation
npm install valita-server zod
# or
yarn add valita-server zodQuick Start (Node HTTP Server)
// src/app.ts
import { createServer, get, log } from "valita-server";
import { z } from "zod";
// Register routes by importing the file once at startup.
get(
"/hello/:name",
{
params: z.object({ name: z.string().min(1) }),
query: z.object({ excited: z.coerce.boolean() }),
},
(req) => ({
status: 200,
body: {
greeting: `Hello ${req.params.name}${req.query.excited ? "!" : "."}`,
},
}),
);
const server = createServer({
errorHandler: (err) => {
console.error("Unhandled error", err);
return {
status: 500,
body: { message: "Internal server error" },
};
},
enableRequestLogging: true,
enableResponseLogging: true,
loggerFn: log.info,
});
server.listen(3000, () => {
console.log("Server listening on http://localhost:3000");
});Tip: Routes are stored in-process. Import your
routes/*.tsmodules before callingcreateServer/createLambdato ensure everything is registered.
Middleware Pipeline
Middleware are functions that receive the request and either return a Response (to short-circuit) or undefined to pass control to the next middleware or controller.
import { get, Request } from "valita-server";
function authMiddleware(req: Request) {
if (!req.headers.authorization) {
return {
status: 401,
body: { message: "Unauthorized" },
};
}
}
get("/secure", authMiddleware, (req) => ({
status: 200,
body: { user: req.headers.authorization },
}));Request Validation with Zod
Attach a schema object anywhere in the middleware chain. Valita merges the pieces into a Zod object and validates the request before hitting your controller.
import { get } from "valita-server";
import { z } from "zod";
const bookSchema = {
params: z.object({ id: z.coerce.number().int().positive() }),
query: z.object({ include: z.enum(["reviews", "author"]).optional() }),
};
get("/books/:id", bookSchema, (req) => ({
status: 200,
body: { id: req.params.id, include: req.query.include },
}));If validation fails, Valita throws a ValidationError that is translated into:
{
"status": 400,
"body": {
"message": "Validation failed",
"error": { "...": "zod-formatted error details" }
}
}You can catch and report these errors differently by providing a custom errorHandler (see below).
File Uploads
Send multipart/form-data. Valita reads the body in the adapter before your route runs, so a middleware does not have to touch the raw stream. Text fields are strings on request.body. Each file is on request.files, keyed by its field name:
import { post, UploadedFile } from "valita-server";
function oneFile(file: UploadedFile | UploadedFile[] | undefined) {
if (!file) {
return undefined;
}
return Array.isArray(file) ? file[0] : file;
}
post("/books/:id/cover", (req) => {
const cover = oneFile(req.files.cover);
if (!cover) {
return { status: 400, body: { message: "Cover file is required" } };
}
return {
status: 201,
body: {
filename: cover.filename,
mediaType: cover.mediaType,
size: cover.data.length,
},
};
});UploadedFile is { filename, mediaType, data }, and data is a Buffer. A repeated field name becomes an array, the same way repeated query keys do. A Zod body schema still validates the text fields. caption on the bookstore cover route is an optional string.
curl -X POST "http://localhost:3000/books/1/cover?userId=123" \
-F "caption=Front cover" \
-F "[email protected];type=image/png"Bodies are limited to 10 MB by default. Set maxBodyBytes on createServer or createLambda to change that. A larger body returns 413 and { "message": "Request body too large" }.
Other body errors:
- Invalid JSON returns
400and{ "message": "Invalid JSON body" }. - A malformed multipart body returns
400and{ "message": "Invalid multipart body" }(or a more specific message, such as a missing boundary).
JsonBodyError, MultipartError, and PayloadTooLargeError are exported. A custom errorHandler replaces the default, so it has to map those errors if you want these status codes.
The parser is a single-level multipart/form-data reader. Parts are separated by CRLF and the boundary. It does not expand nested multiparts or filename* encoding. On Lambda, API Gateway must pass the body through with isBase64Encoded: true. The API Gateway payload limit is 6 MB, which is below Valita's default body limit.
AWS Lambda Handler
Use the same routes and controllers with the Lambda adapter:
// lambda.ts
require("./routes/book.routes"); // registers routes once
import { createLambda } from "valita-server";
export const handler = createLambda({});Deploy it behind API Gateway and Valita will translate the event into the same request object your controllers expect.
Error Handling & Logging Options
Both createServer and createLambda accept an optional Options object:
errorHandler?: (err: Error) => Response— Return a custom response for uncaught errors. If omitted, Valita sends a500or400forValidationError.enableRequestLogging?: boolean— Whentrue, every request is passed tologRequest(path, data).enableResponseLogging?: boolean— Whentrue, every response is passed tologResponse(path, response).loggingFn?: LoggerFn— Override the logging function used by bothlogRequestandlogResponse(defaults toconsole.log).optionsHandler?: OptionsHandler— Fallback forOPTIONSrequests that do not match anoptionsroute. It receives theRequestand returns aResponseorPromise<Response>. If omitted, Valita responds with404and{ message: "No OPTIONS route provided and no options handler set" }. See OPTIONS routes.maxBodyBytes?: number— Maximum request body size in bytes. Defaults to 10 MB. A larger body returns413.
OPTIONS routes
Register a path with options, the same way you register get or post. Middleware and Zod schemas work on that route too.
import { options } from "valita-server";
options("/books/:id", (req) => ({
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
},
}));An OPTIONS request that does not match an options route is passed to optionsHandler when you set it on createServer or createLambda. A typical use is answering CORS preflights for every other path:
import { createServer } from "valita-server";
createServer({
optionsHandler: (req) => ({
status: 204,
headers: {
"Access-Control-Allow-Origin": req.headers.origin ?? "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
}),
}).listen(3000);The handler can be async. If no options route matches and optionsHandler is omitted, Valita responds with 404 and { message: "No OPTIONS route provided and no options handler set" }.
Example: Bookstore API
The repository ships with a runnable example under example/bookstore that demonstrates:
- Registering routes with middleware and schemas
- Serving the same routes via a local HTTP server (
bookstore.app.ts) - Uploading a book cover with
POST /books/:id/cover(upload-cover.controller.ts) - Answering unmatched
OPTIONSrequests withoptionsHandlerwhen nooptionsroute matches (bookstore.app.ts) - Exporting the same logic as an AWS Lambda handler (
bookstore-lambda.ts)
Try it locally
npm install
ts-node example/valita-bookstore/bookstore.app.ts
# Visit http://localhost:3000/books?userId=123With the example server running, upload the sample cover:
powershell -ExecutionPolicy Bypass -File .\example\valita-bookstore\file-upload.ps1That posts example/valita-bookstore/cover.png to POST /books/1/cover?userId=123 and checks for a 201. Pass -FilePath to send a different file.
Try it with Serverless Offline
npm install
npm run example-serverlessThen send requests to the endpoints exposed in example/valita-bookstore/serverless.yml.
Logging
Valita uses batch-stdout for logging by default. If providing a custom logging function, I highly recommend avoiding using console/process.stdout as logging is quite expensive (can cut throughput in half if using request & response logging). Valita exposes it's batch-stdout logger.
import { log, createServer } from "valita-server";
function loggingFn(message: string, obj: any) {
log.info(message, obj);
}
createServer({
loggingFn,
}).listen(3000);Benchmark vs Express.js
Ran benchmark through autocannon script. Not an exact science, but gives an idea of Valita vs Express
Valita

Express

Contributing
- Clone the repo and install dependencies (
npm install). - Run the test suite (
npm test). - Format/lint as needed (
npm run lint).
Issues and pull requests are welcome!
License
MIT © OkayestDev
