Request and response
This page details the complete API of VextJS's request object VextRequest and response object VextResponse.
VextRequest
VextRequest is the unified request object interface of the framework. Each Adapter is responsible for converting the original request of the underlying framework into this interface, ensuring that the business code does not need to be changed when switching Adapters.
Attribute list
method
HTTP request method, always an uppercase string.
app.get("/info", async (req, res) => {
console.log(req.method); // 'GET'
});
url
The complete request URL, including path and query string.
// Request: GET /users?page=1&limit=10
console.log(req.url); // '/users?page=1&limit=10'
path
The path portion of the URL, excluding the query string.
// Request: GET /users?page=1
console.log(req.path); // '/users'
route
The route registration template matched by the current request is automatically injected by each Adapter after the route is matched. The difference from path is that path is the actual request path (high cardinality), and route is the routing template (low cardinality).
This is a key property in solving the high cardinality problem of metrics systems like Prometheus - metrics should be aggregated by routing templates, not actual paths.
// Route registration: app.get('/users/:id', ...)
// Request: GET /users/abc-123
console.log(req.path); // '/users/abc-123' (actual path, high base)
console.log(req.route); // '/users/:id' (route template, low cardinality)✅
// Use req.route as http.route tag in OpenTelemetry / Prometheus
params
Path dynamic parameters. Automatically parsed by the route matching engine.
// Route: /users/:id/posts/:postId
// Request: GET /users/42/posts/7
app.get("/users/:id/posts/:postId", async (req, res) => {
console.log(req.params.id); // '42'
console.log(req.params.postId); // '7'
});
Tip
The value of params is always of type string. If a numeric type is required, use validate + req.valid('param') to obtain the value after automatic type conversion.
query
URL query parameters, parsed into key-value pairs.
// Request: GET /search?keyword=hello&page=2
app.get("/search", async (req, res) => {
console.log(req.query.keyword); // 'hello'
console.log(req.query.page); // '2' (string)
});
Tip
The value of query is always of type string. After using validate to configure query verification, the value after automatic type conversion (such as string '2' → number 2) can be obtained through req.valid('query').
body
The request body data is parsed and filled by the built-in body-parser middleware.
- Before
body-parser middleware is executed, body is undefined
- Supports
application/json and application/x-www-form-urlencoded formats
- You can limit the request body size through
config.bodyParser.maxBodySize
app.post("/users", async (req, res) => {
console.log(req.body); // { name: 'Alice', email: 'alice@example.com' }
});
Request header object, all keys are lowercase.
app.get("/info", async (req, res) => {
const auth = req.headers.authorization; // 'Bearer eyJ...'
const ct = req.headers["content-type"]; // 'application/json'
const custom = req.headers["x-custom"]; // Custom request headers
});
app
The VextApp application instance to which the current request belongs.
Route handlers usually access app directly through the closure of defineRoutes. But routing-level middleware does not have closures, and the framework capabilities must be accessed through req.app:
//Access through req.app in middleware
import { defineMiddleware } from "vextjs";
export default defineMiddleware(async (req, _res, next) => {
req.app.logger.info("Middleware is executing");
if (!req.headers.authorization) {
req.app.throw(401, "Authentication token not provided");
}
await next();
});
Capabilities accessible via req.app:
requestId
Request unique identifier for log correlation and distributed link tracing.
Generate rules:
- Prioritize transparent transmission from the request header
x-request-id (configurable) (applicable to scenarios where the gateway/proxy has generated an ID)
- When the request header does not exist, the framework automatically generates UUID v4
- You can customize the generation algorithm through
config.requestId.generate or app.setRequestIdGenerator()
app.get("/info", async (req, res) => {
console.log(req.requestId); // '550e8400-e29b-41d4-a716-446655440000'
// The log automatically carries requestId (through AsyncLocalStorage)
req.app.logger.info("Processing request");
// → { requestId: '550e8400-...', msg: 'Processing request' }
});
ip
Client IP address.
app.get("/info", async (req, res) => {
console.log(req.ip); // '192.168.1.100'
});
Warning
When deployed behind a reverse proxy (Nginx/Cloud Load Balancer), trustProxy: true must be set, otherwise req.ip is always the IP of the proxy server.
protocol
Request agreement.
app.get("/info", async (req, res) => {
console.log(req.protocol); // 'https'
});
valid(location)
Get the data after validate verification and type conversion.
function valid<T = Record<string, any>>(
location: "query" | "body" | "param" | "header",
): T;
Parameters:
location and data source mapping:
Tip
Note that location uses the singular 'param`` (consistent with the key configured in validate), but the underlying data source is the **plural** req.params`. The frame internals are mapped correctly.
Basic Usage:
app.get(
"/users",
{
validate: {
query: { page: "number:1-", limit: "number:1-100" },
},
},
async (req, res) => {
const { page, limit } = req.valid("query");
// page: number (automatically converted from string '1' to number 1)
// limit: number
},
);
Generic usage:
interface UserQuery {
page: number;
limit: number;
keyword?: string;
}
const query = req.valid<UserQuery>("query");
// query.page → IDE prompt number
// query.limit → IDE prompt number
// query.keyword → IDE prompt string | undefined
Multiple location verification:
app.put(
"/users/:id",
{
validate: {
param: { id: "string:1-" },
body: { name: "string:1-50", email: "email" },
query: { notify: "boolean?" },
},
},
async (req, res) => {
const { id } = req.valid("param");
const body = req.valid("body");
const { notify } = req.valid("query");
},
);
Warning
The corresponding location must be configured in options.validate before req.valid() can be called. Calling req.valid() in an unconfigured location returns undefined.
onClose(handler)
Register request shutdown hook, triggered when the client disconnects.
function onClose(handler: () => void): void;
Mainly used in long-term connection scenarios such as SSE / WebSocket, and cleans up resources when the client disconnects:
app.get("/sse", async (req, res) => {
const stream = createSSEStream();
req.onClose(() => {
stream.close();
console.log("Client disconnected");
});
res.stream(stream, "text/event-stream");
});
Tip
The framework will automatically clear the hooks array after the hooks are executed. There is no need to manually remove it, and there will be no memory leaks caused by closure references.
t(key, params?)
i18n translation function, injected by i18n plugin. undefined when i18n is not enabled.
function t(key: string, params?: Record<string, unknown>): string;
Usage:
app.get("/greeting", async (req, res) => {
if (req.t) {
const message = req.t("welcome", { name: "Alice" });
// → 'Welcome, Alice' (Chinese) or 'Welcome, Alice' (English)
res.json({ message });
}
});
files
File upload list, initial status is undefined. It needs to be set up with a file upload plug-in (such as the busboy plug-in). Each element conforms to the ParsedFile interface:
interface ParsedFile {
fieldname: string; // form field name
filename: string; // Upload file name
mimetype: string; // MIME type, such as 'image/png'
buffer: Buffer; //Original content of the file
size: number; //The number of bytes in the file
}
app.post("/upload", { middlewares: ["upload"] }, async (req, res) => {
const file = req.files?.[0];
if (!file) {
res.json({ code: 400, message: "File not uploaded" }, 400);
return;
}
// file.buffer complete Buffer containing file content
res.json({ filename: file.filename, size: file.size });
});
_getRawBodyBuffer()
ℹ️ This is an internal method of the framework, mainly used by plug-in developers.
_getRawBodyBuffer(): Promise<Buffer>
Returns a Buffer of the original request body. Each adapter is guaranteed to consume the data stream only once, and the results are cached internally. This is the basic method to implement the file upload plug-in:
// Plug-in example (use busboy to parse multipart/form-data)
import { createBusboy } from 'busboy';
import type { ParsedFile } from 'vextjs';
export default definePlugin(async (app) => {
app.use(async (req, _res, next) => {
const ct = req.headers['content-type'] ?? '';
if (!ct.startsWith('multipart/form-data')) { await next(); return; }
const rawBuffer = await req._getRawBodyBuffer();
const files: ParsedFile[] = await parseMultipart(rawBuffer, ct);
req.files = files;
await next();
});
});
Extended fields
Middleware and plugins can mount custom fields on req. Type hints are available through the declare module extension interface:
// types/vext.d.ts
declare module "vextjs" {
interface VextRequest {
user?: {
id: string;
role: "admin" | "user";
};
}
}
// Set in middleware
export default defineMiddleware(async (req, _res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
req.user = await verifyToken(token);
await next();
});
// used in handler
app.get("/profile", { middlewares: ["auth"] }, async (req, res) => {
res.json(req.user); // IDE knows the type is { id: string; role: 'admin' | 'user' }
});
VextResponse
VextResponse is the unified response object interface of the framework. Provides JSON response, text response, streaming response, redirection and other capabilities.
List of methods
json(data, status?)
Returns a JSON response. This is the most common response method.
function json(data: unknown, status?: number): void;
Parameters:
Export Packaging:
When config.response.wrap is true (default), res.json(data) is automatically wrapped:
res.json({ id: 1, name: "Alice" });
// Actual response:
// {
// "code": 0,
// "data": { "id": 1, "name": "Alice" },
// "requestId": "550e8400-e29b-41d4-a716-446655440000"
// }
When config.response.wrap is false, send raw data directly:
res.json({ id: 1, name: "Alice" });
// Actual response:
// { "id": 1, "name": "Alice" }
Specify status code:
// 201 Created
res.json(newUser, 201);
// You can also use chain calls
res.status(201).json(newUser);
204 No Content:
Regardless of whether the wrapper is opened or not, the 204 status code does not send the message body (conforming to RFC 9110 §15.3.5):
res.status(204).json(null);
// Response: 204 No Content (no body)
Error response (usually handled automatically by the framework error-handler):
{
"code": 10001,
"message": "User does not exist",
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
text(content, status?)
Returns a plain text response, without export wrapping.
function text(content: string, status?: number): void;
app.get("/health", async (_req, res) => {
res.text("OK");
});
app.get("/version", async (_req, res) => {
res.text("v1.0.0", 200);
});
Automatically set Content-Type: text/plain; charset=utf-8.
stream(readable, contentType?)
Streaming responses for large file transfers or real-time data streaming.
function stream(readable: NodeJS.ReadableStream, contentType?: string): void;
Parameters:
import { createReadStream } from "node:fs";
app.get("/large-file", async (_req, res) => {
const stream = createReadStream("/path/to/large-file.csv");
res.stream(stream, "text/csv");
});
SSE (Server-Sent Events):
app.get("/events", async (req, res) => {
const stream = new ReadableStream({
start(controller) {
const interval = setInterval(() => {
controller.enqueue(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 1000);
req.onClose(() => {
clearInterval(interval);
controller.close();
});
},
});
res.stream(stream, "text/event-stream");
});
download(readable, filename, contentType?)
In file download response, the Content-Disposition: attachment header is automatically set.
function download(
readable: NodeJS.ReadableStream,
filename: string,
contentType?: string,
): void;
Parameters:
import { createReadStream } from "node:fs";
app.get("/export", async (_req, res) => {
const stream = createReadStream("/path/to/report.xlsx");
res.download(
stream,
"report-2026.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
After the browser receives the response, a file download dialog box will pop up.
redirect(url, status?)
HTTP redirect.
function redirect(url: string, status?: 301 | 302 | 307 | 308): void;
Parameters:
// Temporary redirect (302)
res.redirect("/new-page");
// Permanent redirect (301)
res.redirect("/new-permanent-page", 301);
// Temporary redirection retention method (307)
res.redirect("/api/v2/users", 307);
// Permanent redirection retention method (308)
res.redirect("/api/v2/users", 308);
Redirect status code description:
status(code)
Set HTTP status code and support chain calls.
function status(code: number): this;
//Chain call
res.status(201).json(newUser);
res.status(204).json(null);
res.status(404).json({ message: "Not found" });
If status() is not called, the default status code is 200. It can also be set directly through the second parameter of json(data, status).
Set response headers to support chain calls.
function setHeader(name: string, value: string): this;
res
.setHeader("X-Custom-Header", "custom-value")
.setHeader("Cache-Control", "no-cache")
.json(data);
Common response headers:
// cache control
res.setHeader("Cache-Control", "public, max-age=3600");
//Content processing
res.setHeader("Content-Disposition", 'inline; filename="preview.pdf"');
// security related
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
// Custom business header
res.setHeader("X-RateLimit-Remaining", "95");
statusCode (read-only)
Get the current HTTP status code.
readonly statusCode: number;
Mainly used for onion model after-middleware, reading the response status code after await next():
import { defineMiddleware } from "vextjs";
export default defineMiddleware(async (req, res, next) => {
const start = Date.now();
await next(); // handler execution completed
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} → ${res.statusCode} (${duration}ms)`);
// GET /users → 200 (12ms)
});
VextPublicResponse
User-visible response types, excluding internal methods via Omit:
type VextPublicResponse = Omit<VextResponse, "_enableWrap" | "rawJson">;
In the route handler's type signature, the res parameter actually uses VextResponse (containing internal methods), but user code generally does not need to call _enableWrap() and rawJson() - these are used by the framework's internal response-wrapper and error-handler middleware.
Internal method (not recommended for direct use)
rawJson(data, status?)
Returns raw JSON, without export wrapping. For use by the framework's internal error-handler only.
function rawJson(data: unknown, status?: number): void;
//Use error-handler inside the framework
res.rawJson(
{
code: -1,
message: "Internal Server Error",
requestId: req.requestId,
},
500,
);
Warning
User code should not call rawJson() directly. To bypass egress wrapping, set config.response.wrap: false and then use standard res.json().
_enableWrap()
Turn on the export packaging sign. Only called by the built-in response-wrapper middleware.
function _enableWrap(): void;
After the call, subsequent json() calls will automatically wrap the response body into the { code: 0, data, requestId } format.
Usage mode
Standard CRUD response
export default defineRoutes((app) => {
// List query
app.get("/list", async (req, res) => {
const items = await app.services.item.findAll();
res.json(items);
// → { code: 0, data: [...], requestId: '...' }
});
// create
app.post("/", async (req, res) => {
const item = await app.services.item.create(req.valid("body"));
res.json(item, 201);
// → 201 { code: 0, data: { id: '...' }, requestId: '...' }
});
// update
app.put("/:id", async (req, res) => {
const item = await app.services.item.update(
req.valid("param").id,
req.valid("body"),
);
res.json(item);
});
// delete
app.delete("/:id", async (req, res) => {
await app.services.item.delete(req.valid("param").id);
res.status(204).json(null);
// → 204 No Content
});
});
Error handling
export default defineRoutes((app) => {
app.get("/:id", async (req, res) => {
const user = await app.services.user.findById(req.params.id);
if (!user) {
// Automatically captured by the framework and converted to standard error response
app.throw(404, "User does not exist");
}
res.json(user);
});
});
Errors thrown by app.throw() are uniformly captured by the framework error-handler middleware and converted into standard error responses:
{
"code": 404,
"message": "User does not exist",
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
If you need to actively return an explicit HTTP error, use app.throw(...). If there is an unexpected runtime failure, you can also directly throw new Error("..."), and the framework will capture it as 500; when response.hideInternalErrors = false, the JSON 500 response in the development environment will be additionally accompanied by stack.
app.post("/upload", async (req, res) => {
const result = await processUpload(req.body);res
.status(201)
.setHeader("Location", `/files/${result.id}`)
.setHeader("X-File-Size", String(result.size))
.json(result);
});
Streaming file download
import { createReadStream, statSync } from "node:fs";
import { join } from "node:path";
app.get("/download/:filename", async (req, res) => {
const filepath = join("/data/files", req.params.filename);
try {
const stat = statSync(filepath);
const stream = createReadStream(filepath);
res
.setHeader("Content-Length", String(stat.size))
.download(stream, req.params.filename);
} catch {
app.throw(404, "File does not exist");
}
});
Conditional response
app.get("/users/:id", async (req, res) => {
const user = await app.services.user.findById(req.valid("param").id);
if (!user) {
app.throw(404, "User does not exist");
}
//Determine the response format based on the request header
if (req.headers.accept === "text/plain") {
res.text(`User: ${user.name} <${user.email}>`);
} else {
res.json(user);
}
});
Requests and responses in middleware
Onion model
Middleware implements the onion model through await next(), which can handle requests and responses before and after the handler is executed:
import { defineMiddleware } from "vextjs";
export default defineMiddleware(async (req, res, next) => {
// ── before handler ──
const start = Date.now();
req.app.logger.info({ method: req.method, path: req.path }, "Request starts");
await next(); // Execute handler (and subsequent middleware)
// ── after handler ──
const duration = Date.now() - start;
req.app.logger.info(
{
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`,
},
"Request completed",
);
});
Modify request
Middleware can modify the request object before next():
export default defineMiddleware(async (req, _res, next) => {
// Parse JWT and inject user information
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
req.user = await verifyJWT(token);
}
await next();
});
Short circuit response
Middleware can return the response directly without calling next() (short circuit):
export default defineMiddleware(async (req, res, next) => {
if (isBlacklisted(req.ip)) {
res.status(403).json({ message: "Access Denied" });
return; // If next() is not called, the handler will not be executed.
}
await next();
});
Type import
import type { VextRequest, VextResponse, VextPublicResponse } from "vextjs";
These types usually do not need to be imported explicitly - the types of req and res are automatically inferred by TypeScript in the callbacks of defineRoutes and defineMiddleware. Explicitly imported types are only necessary when writing stand-alone utility functions:
import type { VextRequest } from "vextjs";
function extractUser(req: VextRequest) {
return req.user;
}