Cpeak is a minimal and fast Node.js framework inspired by Express.js.
This project is designed to be improved until it's ready for use in complex production applications, aiming to be more performant and minimal than Express.js. This framework is intended for HTTP applications that primarily deal with JSON and file-based message bodies.
This is an educational project that was started as part of the Understanding Node.js: Core Concepts course. If you want to learn how to build a framework like this, and get to a point where you can build things like this yourself, check out this course!
- Minimalism: No unnecessary bloat, with zero dependencies. Just the core essentials you need to build fast and reliable applications.
- Performance: Engineered to be fast, Cpeak won’t sacrifice speed for excessive customizability.
- Educational: Every new change made in the project will be explained in great detail in this YouTube playlist. Follow this project and let's see what it takes to build an industry-leading product!
- Express.js Compatible: You can easily refactor from Cpeak to Express.js and vice versa. Many npm packages that work with Express.js will also work with Cpeak.
Ready to dive in? Install Cpeak via npm:
npm install cpeakCpeak is a pure ESM package, and to use it, your project needs to be an ESM as well. You can learn more about that here.
import cpeak from "cpeak";
const server = cpeak();
server.route("get", "/", (req, res) => {
res.json({ message: "Hi there!" });
});
server.listen(3000, () => {
console.log("Server has started on port 3000");
});Include the framework like this:
import cpeak from "cpeak";Because of the minimalistic philosophy, you won’t add unnecessary objects to your memory as soon as you include the framework. If at any point you want to use a particular utility function (like parseJSON and serveStatic), include it like the line below, and only at that point will it be moved into memory:
import cpeak, { serveStatic, parseJSON } from "cpeak";Initialize the Cpeak server like this:
const server = cpeak();Now you can use this server object to start listening, add route logic, add middleware functions, and handle errors.
If you add a middleware function, that function will run before your route logic kicks in. Here you can customize the request object, return an error, or do anything else you want to do prior to your route logic, like authentication.
After calling next, the next middleware function is going to run if there’s any; otherwise, the route logic is going to run.
server.beforeEach((req, res, next) => {
if (req.headers.authentication) {
// Your authentication logic...
req.userId = "<something>";
req.custom = "This is some string";
next();
} else {
// Return an error and close the request...
return res.status(401).json({ error: "Unauthorized" });
}
});
server.beforeEach((req, res, next) => {
console.log(
"The custom value was added from the previous middleware: ",
req.custom
);
next();
});You can also add middleware functions for a particular route handler like this:
const requireAuth = (req, res, next) => {
// Check if user is logged in, if so then:
req.test = "this is a test value";
next();
// If user is not logged in:
throw { status: 401, message: "Unauthorized" };
};
server.route("get", "/profile", requireAuth, (req, res) => {
console.log(req.test); // this is a test value
});You can add as many middleware functions as you want for a route:
server.route(
"get",
"/profile",
requireAuth,
anotherFunction,
oneMore,
(req, res) => {
// your logic
}
);You can add new routes like this:
server.route("patch", "/the-path-you-want", (req, res) => {
// your route logic
});First add the HTTP method name you want to handle, then the path, and finally, the callback. The req and res object types are the same as in the Node.js HTTP module (http.IncomingMessage and http.ServerResponse). You can read more about them in the official Node.js documentation.
To be more consistent with the broader Node.js community and frameworks, we call the HTTP URL parameters (query strings) 'query', and the path variables (route parameters) 'params'.
Here’s how we can read both:
// Imagine request URL is example.com/test/my-title/more-text?filter=newest
server.route("patch", "/test/:title/more-text", (req, res) => {
const title = req.params.title;
const filter = req.query.filter;
console.log(title); // my-title
console.log(filter); // newest
});You can send a file as a Node.js Stream anywhere in your route or middleware logic like this:
server.route("get", "/testing", (req, res) => {
return res.status(200).sendFile("<file-path>", "<mime-type>");
// Example:
// return res.status(200).sendFile("./images/sun.jpeg", "image/jpeg");
});The file’s binary content will be in the HTTP response body content. Make sure you specify a correct path relative to your CWD (use the path module for better compatibility) and also the correct HTTP MIME type for that file.
If you want to redirect to a new URL, you can simply do:
res.redirect("https://whatever.com");You can enable HTTP response compression at construction time. Once enabled, serveStatic, res.json() and res.sendFile() will compress eligible responses automatically, and you also get a res.compress() method on the response for custom payloads.
Fire it up with the defaults like this:
const server = cpeak({ compression: true });Or pass options to tune the behavior:
const server = cpeak({
compression: {
threshold: 1024, // bytes — responses smaller than this are sent uncompressed. Default: 1024
brotli: {}, // node:zlib BrotliOptions
gzip: {}, // node:zlib ZlibOptions
deflate: {} // node:zlib ZlibOptions
}
});For arbitrary payloads, like a Buffer, string, or Readable stream, use res.compress:
server.route("get", "/report", async (req, res) => {
const csv = await buildCsvReport();
await res.compress("text/csv", csv);
});When you're streaming, you can pass a known size as the third argument. Cpeak will use it to decide eligibility against threshold, and to set Content-Length if the body ends up being sent uncompressed:
import { Readable } from "node:stream";
server.route("get", "/proxy/feed", async (req, res) => {
const upstream = await fetch("https://example.com/feed.xml");
const size = Number(upstream.headers.get("content-length"));
await res.compress("application/xml", Readable.fromWeb(upstream.body), size);
});You must first enable compression at construction time to use res.compress.
One thing to keep in mind: when compression is enabled, res.json() returns a Promise because the work runs through async streams. You don't have to await it, but you can if you want to know when the response has been fully flushed.
If anywhere in your route functions or route middleware functions you want to return an error, you can just throw the error and let the automatic error handler catch it:
server.route("get", "/api/document/:title", (req, res) => {
const title = req.params.title;
if (title.length > 500) throw { status: 400, message: "Title too long." };
// The rest of your logic...
});You can also make use of the handleErr callback function like this:
server.route("get", "/api/document/:title", (req, res, handleErr) => {
const title = req.params.title;
if (title.length > 500)
return handleErr({ status: 400, message: "Title too long." });
// The rest of your logic...
});Make sure to call the server.handleErr and pass a function like this to have the automatic error handler work properly:
server.handleErr((error, req, res) => {
if (error && error.status) {
res.status(error.status).json({ error: error.message });
} else {
// Log the unexpected errors somewhere so you can keep track of them...
console.error(error);
res.status(500).json({
error: "Sorry, something unexpected happened on our side."
});
}
});The error object is the object that you threw or passed to the handleErr function earlier in your routes.
Start listening on a specific port like this:
server.listen(3000, () => {
console.log("Server has started on port 3000");
});There are utility functions that you can include and use as middleware functions. These are meant to make it easier for you to build HTTP applications. In the future, many more will be added, and you only move them into memory once you include them. No need to have many npm dependencies for simple applications!
The list of utility functions as of now:
- serveStatic
- parseJSON
- render
- cookieParser
- swagger
- auth
- cors
Including any one of them is done like this:
import cpeak, { utilName } from "cpeak";With this middleware function, you can automatically set a folder in your project to be served by Cpeak. Here’s how to set it up:
server.beforeEach(
serveStatic("./public", {
mp3: "audio/mpeg"
})
);If you have file types in your public folder that are not one of the following, make sure to add the MIME types manually as the second argument in the function as an object where each property key is the file extension, and each value is the correct MIME type for that. You can see all the available MIME types on the IANA website.
html: "text/html",
css: "text/css",
js: "application/javascript",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
svg: "image/svg+xml",
gif: "image/gif",
ico: "image/x-icon",
txt: "text/plain",
json: "application/json",
webmanifest: "application/manifest+json",
eot: "application/vnd.ms-fontobject",
otf: "font/otf",
ttf: "font/ttf",
woff: "font/woff",
woff2: "font/woff2"
You can also serve your static files under a URL prefix by passing a third argument with a prefix option. This is useful when you want all static assets to live under a specific path like /static:
server.beforeEach(
serveStatic("./public", null, { prefix: "/static" })
);With this setup, a file at ./public/app.js would be served at /static/app.js instead of /app.js. Pass null as the second argument if you don’t need any custom MIME types.
With this middleware function, you can easily read and send JSON in HTTP message bodies in route and middleware functions. Fire it up like this:
// You can pass an optional limit option to indicate the maximum
// JSON body size that your server will accept.
server.beforeEach(parseJSON({ limit: 1024 * 1024 })); // default value is 1024 * 1024 (1MB)Read and send JSON from HTTP messages like this:
server.route("put", "/api/user", (req, res) => {
// Reading JSON from the HTTP request:
const email = req.body.email;
// rest of your logic...
// Sending JSON in the HTTP response:
res.status(201).json({ message: "Something was created..." });
});With this function you can do server side rendering before sending a file to a client. This can be useful for dynamic customization and search engine optimization.
First fire it up like this:
server.beforeEach(render());And then for rendering:
server.route("get", "/", (req, res, next) => {
return res.render(
"./public/index.html",
{
title: "Page title",
name: "Allan"
},
"text/html"
);
});You can then inject the variables into your file in {{ variable_name }} like this:
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<h1>{{ name }}</h1>
</body>
</html>With this middleware function, you can easily read and set cookies in your route and middleware functions. Fire it up like this:
server.beforeEach(cookieParser());If you need to use signed cookies, pass a secret:
server.beforeEach(cookieParser({ secret: "your-secret-key" }));Signed cookies use HMAC to verify integrity. The original value stays readable by the client, but any tampering with it will be detected on the server side. This makes them a solid choice for session identifiers or user IDs where you want to prevent impersonation without hiding the value itself.
Read incoming cookies like this:
server.route("get", "/dashboard", (req, res) => {
// Regular cookies
const theme = req.cookies.theme;
// Signed cookies — returns false if the signature is invalid or the value was tampered with
const userId = req.signedCookies.userId;
res.status(200).json({ theme, userId });
});Set cookies on the response like this:
server.route("post", "/login", (req, res) => {
// A plain cookie
res.cookie("theme", "dark", { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000 });
// A signed cookie
res.cookie("userId", "abc123", { signed: true, httpOnly: true, secure: true });
res.status(200).json({ message: "Logged in" });
});Clear a cookie like this:
res.clearCookie("userId");The full list of cookie options you can pass as the third argument to res.cookie():
signed— sign the cookie value with HMAC using the secret you provided tocookieParserhttpOnly— prevents client-side JavaScript from accessing the cookiesecure— instructs the browser to send the cookie only over HTTPSsameSite— controls cross-site cookie behavior; accepts"strict","lax", or"none"maxAge— cookie lifetime in millisecondsexpires— a specific expirationDatefor the cookiepath— path the cookie is valid for (defaults to"/")domain— domain the cookie is valid for
With this middleware function, you can serve an interactive Swagger UI for your API documentation. It works alongside the serveStatic utility and two npm packages: swagger-ui-dist (the Swagger UI static assets) and yamljs (to load your YAML spec file).
Start by installing the dependencies:
npm install swagger-ui-dist yamljsThen fire it up like this:
import cpeak, { swagger, serveStatic } from "cpeak";
import YAML from "yamljs";
import swaggerUiDist from "swagger-ui-dist";
import path from "node:path";
const server = cpeak();
const swaggerDocument = YAML.load(
path.join(path.resolve(), "./src/swagger.yml")
);
server.beforeEach(swagger(swaggerDocument));
server.beforeEach(
serveStatic(swaggerUiDist.getAbsoluteFSPath(), undefined, {
prefix: "/api-docs",
})
);Once set up, your Swagger UI will be available at /api-docs. The swagger middleware handles serving your spec at /api-docs/spec.json and wiring up the Swagger UI initializer, while serveStatic serves all the Swagger UI static assets under the same prefix.
If you want to serve the docs under a different path, pass it as the second argument to swagger and match the prefix in serveStatic:
server.beforeEach(swagger(swaggerDocument, "/docs"));
server.beforeEach(
serveStatic(swaggerUiDist.getAbsoluteFSPath(), undefined, {
prefix: "/docs",
})
);With this middleware you can add a full-fledged authentication system to your application with emails, username and password authentication, with features such as Forgot Password, Update Password and so forth. We have no external dependencies, with timing-safe comparisons throughout. It attaches helper methods directly to req so your route handlers stay clean.
Fire it up like this:
import cpeak, { parseJSON, cookieParser, auth } from "cpeak";
const app = cpeak();
app.beforeEach(parseJSON());
app.beforeEach(cookieParser());
app.beforeEach(
auth({
// Required
secret: "your-secret-min-32-chars-long!!!", // used to sign token IDs with HMAC
saveToken: async (tokenId, userId, expiresAt) => { /* store in your DB */ },
findToken: async (tokenId) => { /* return { userId, expiresAt } or null */ },
// Enables req.logout()
revokeToken: async (tokenId) => { /* delete from your DB */ },
// Optional PBKDF2 tuning (defaults shown):
iterations: 210_000, // higher = slower brute-force
keylen: 64, // derived key length in bytes
digest: "sha512",
saltSize: 32,
// Optional token signing tuning (defaults shown):
hmacAlgorithm: "sha256",
tokenIdSize: 20,
tokenExpiry: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
})
);Once set up, the following methods are available on req inside your routes and middleware:
| Method | Description |
|---|---|
req.hashPassword({ password }) |
Hashes a password with PBKDF2. Store the result; never store plaintext. |
req.login({ password, hashedPassword, userId }) |
Verifies the password and if correct, creates a signed token. Returns the token string to send to the client, or null on wrong password. |
req.verifyToken(token) |
Validates a token's HMAC signature and expiry. Returns { userId } or null. |
req.logout(token) |
Revokes the token via your revokeToken callback. Only available when revokeToken is provided. |
Here are the two most common middleware patterns you'll want to set up:
// Throws 401 if the request has no valid token. Use on protected routes.
const requireAuth = async (req, res, next) => {
const token = req.headers["authorization"];
if (!token) throw { status: 401, message: "Unauthorized." };
const result = await req.verifyToken(token);
if (!result) throw { status: 401, message: "Unauthorized." };
req.user = { id: result.userId };
next();
};
// Silently sets req.user when a valid token is present, but lets the request through either way.
// Useful for routes accessible by both authenticated and unauthenticated users.
const optionalAuth = async (req, _res, next) => {
const token = req.headers["authorization"];
if (token) {
const result = await req.verifyToken(token);
if (result) req.user = { id: result.userId };
}
next();
};For complete working examples, see:
examples/auth-localstorage.js— token sent via theAuthorizationheader (suited for SPAs and mobile clients)examples/auth-cookies.js— token stored in anhttpOnlycookie (protects against XSS)
The CORS middleware allows you to enable Cross-Origin Resource Sharing in your application.
server.beforeEach(cors({
origin: "http://localhost:3000", // string, string[], RegExp, boolean, or async (origin) => boolean. Default: "*" (all origins)
methods: "GET,POST,PUT,DELETE", // allowed HTTP methods. Default: "GET,HEAD,PUT,PATCH,POST,DELETE"
allowedHeaders: "Content-Type", // headers the browser may send. Default: echoes request headers for origin:"*", else "Content-Type, Authorization"
exposedHeaders: "X-Request-Id", // response headers the browser may read. Default: none
credentials: true, // adds Access-Control-Allow-Credentials: true. Default: false
maxAge: 3600, // seconds to cache preflight result in the browser. Default: 86400
preflightContinue: false, // pass OPTIONS preflight to next middleware instead of auto-responding. Default: false
optionsSuccessStatus: 204 // status code for successful preflight responses. Default: 204
}));Or if you don't care and want to allow everything with the default settings, just do:
server.beforeEach(cors());Here you can see all the features that Cpeak offers (excluding the authentication features), in one small piece of code:
import cpeak, { serveStatic, parseJSON, render, cookieParser, cors } from "cpeak";
const server = cpeak();
server.beforeEach(
serveStatic("./public", {
mp3: "audio/mpeg"
})
);
server.beforeEach(render());
// For parsing JSON bodies
server.beforeEach(parseJSON());
// For reading and setting cookies
server.beforeEach(cookieParser({ secret: "your-secret-key" }));
// For enabling CORS
server.beforeEach(cors({
origin: "http://localhost:3000",
credentials: true,
methods: "GET,POST,PUT,DELETE"
}));
// Adding custom middleware functions
server.beforeEach((req, res, next) => {
req.custom = "This is some string";
next();
});
// A middleware function that can be specified to run before some particular routes
const testRouteMiddleware = (req, res, next) => {
req.whatever = "some calculated value maybe";
if (req.params.test !== "something special") {
throw { status: 400, message: "an error message" };
}
next();
};
// Adding route handlers
server.route("get", "/", (req, res, next) => {
return res.render(
"<path-to-file-relative-to-cwd>",
{
test: "some testing value",
number: "2343242"
},
"<mime-type>"
);
});
server.route("get", "/old-url", testRouteMiddleware, (req, res, next) => {
return res.redirect("/new-url");
});
server.route("get", "/api/document/:title", testRouteMiddleware, (req, res) => {
// Reading URL variables (route parameters)
const title = req.params.title;
// Reading URL parameters (query strings) (like /users?filter=active)
const filter = req.query.filter;
// Reading JSON request body
const anything = req.body.anything;
// Handling errors
if (anything === "not-expected-thing")
throw { status: 400, message: "Invalid property." };
// Sending a JSON response
res.status(200).json({ message: "This is a test response" });
});
// Reading and setting cookies
server.route("post", "/login", (req, res) => {
// Reads are available via req.cookies and req.signedCookies
const sessionId = req.signedCookies.sessionId;
// Set a signed session cookie
res.cookie("sessionId", "abc123", { signed: true, httpOnly: true, secure: true });
res.status(200).json({ message: "Logged in" });
});
// Sending a file response
server.route("get", "/file", (req, res) => {
// Make sure to specify a correct path and MIME type...
res.status(200).sendFile("<path-to-file-relative-to-cwd>", "<mime-type>");
});
// Handle all the errors that could happen in the routes
server.handleErr((error, req, res) => {
if (error && error.status) {
res.status(error.status).json({ error: error.message });
} else {
console.error(error);
res.status(500).json({
error: "Sorry, something unexpected happened from our side."
});
}
});
server.listen(3000, () => {
console.log("Server has started on port 3000");
});Version 1.x.x represents the initial release of our framework, developed during the Understanding Node.js Core Concepts course. These versions laid the foundation for our project.
All version 2.x.x releases are considered to be in active development, following the completion of the course. These versions include ongoing feature additions and API changes as we refine the framework. Frequent updates may require code changes, so version 2.x.x is not recommended for production environments.
For new features, bug fixes, and other changes that don't break existing code, the patch version will be increased. For changes that break existing code, the minor version will be increased.
Version 3.x.x and beyond will be our first production-ready releases. These versions are intended for stable, long-term use, with a focus on backward compatibility and minimal breaking changes.