Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ app.get("/health", (req, res) => {

```javascript
const app = woodland({
origins: ["https://myapp.com", "http://localhost:3000"],
origins: ["https://myapp.com", "http://localhost:3000"],
});
// Woodland handles preflight OPTIONS automatically
```
Expand Down Expand Up @@ -163,14 +163,13 @@ Set `app.error` to intercept all unhandled errors before the error middleware ch
const app = woodland();

app.error = (err, _req, res) => {
console.error("Unhandled error:", err);
res.status(500).send("Internal server error");
console.error("Unhandled error:", err);
res.status(500).send("Internal server error");
};
```

The handler receives 3 arguments `(err, req, res)` and must terminate the request itself. When set, the error middleware chain is skipped.


## Configuration

```javascript
Expand Down Expand Up @@ -199,20 +198,22 @@ res.redirect("/new-url");
res.header("x-custom", "value");
res.status(201);
res.error(404);
res.cookie("session", "abc123", { httpOnly: true, maxAge: 3600000 });
res.clearCookie("session");
```

## Request Properties

```javascript
req.ip; // Client IP address
req.params; // URL parameters { id: "123" }
req.parsed; // URL object
req.allow; // Allowed methods
req.cors; // CORS enabled
req.body; // Request body
req.host; // Hostname
req.valid; // Request validity
req.app; // Woodland application instance (provides access to app.error)
req.ip; // Client IP address
req.params; // URL parameters { id: "123" }
req.parsed; // URL object
req.allow; // Allowed methods
req.cors; // CORS enabled
req.body; // Request body
req.host; // Hostname
req.valid; // Request validity
req.app; // Woodland application instance (provides access to app.error)
```

## Event Handlers
Expand Down Expand Up @@ -270,7 +271,7 @@ npx woodland --ip=0.0.0.0
## Testing

```bash
npm test # Run tests (334 tests, 100% line, 99.37% function, 95.90% branch coverage)
npm test # Run tests (365 tests, 99.45% line, 98.82% function coverage)
npm run coverage # Generate coverage report
npm run benchmark # Performance benchmarks
npm run lint # Check linting
Expand All @@ -287,11 +288,11 @@ npm run lint # Check linting

Woodland delivers **enterprise-grade security without sacrificing performance**. Security features add minimal overhead.

| Framework | Security Approach | Mean Response Time |
|-----------|------------------|-------------------|
| Fastify | Requires plugins | 0.1491ms |
| **Woodland** | **Built-in** | **0.1866ms** |
| Express | Requires middleware | 0.1956ms |
| Framework | Security Approach | Mean Response Time |
| ------------ | ------------------- | ------------------ |
| Fastify | Requires plugins | 0.1491ms |
| **Woodland** | **Built-in** | **0.1866ms** |
| Express | Requires middleware | 0.1956ms |

## Security

Expand All @@ -314,15 +315,16 @@ import rateLimit from "express-rate-limit";
app.always(helmet());
app.always(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests
standardHeaders: true,
legacyHeaders: false,
}),
);
```

**Security Warning:**

> ⚠️ **Production Deployment**: Always use a reverse proxy (nginx, Cloudflare) in production for SSL/TLS termination, DDoS protection, and additional security layers.

## License
Expand Down
14 changes: 7 additions & 7 deletions coverage.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
ℹ start of coverage report
ℹ -------------------------------------------------------------------------------------------
ℹ ---------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ -------------------------------------------------------------------------------------------
ℹ ---------------------------------------------------------------
ℹ src | | | |
ℹ cli-utils.js | 100.00 | 100.00 | 100.00 |
ℹ config.js | 100.00 | 89.47 | 100.00 |
Expand All @@ -10,9 +10,9 @@
ℹ logger.js | 100.00 | 94.23 | 95.45 |
ℹ middleware.js | 100.00 | 100.00 | 100.00 |
ℹ request.js | 100.00 | 100.00 | 100.00 |
ℹ response.js | 100.00 | 97.73 | 100.00 |
ℹ woodland.js | 98.62 | 92.16 | 98.04 | 237-238 242 323-324 440-441 443-444 446-447
ℹ -------------------------------------------------------------------------------------------
ℹ all files | 99.63 | 95.76 | 98.77 |
ℹ -------------------------------------------------------------------------------------------
ℹ response.js | 100.00 | 96.43 | 100.00 |
ℹ woodland.js | 100.00 | 96.23 | 100.00 |
ℹ ---------------------------------------------------------------
ℹ all files | 100.00 | 96.23 | 99.41 |
ℹ ---------------------------------------------------------------
ℹ end of coverage report
141 changes: 135 additions & 6 deletions dist/woodland.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,79 @@ const HTML_ESCAPES = Object.freeze({
"'": "'",
});

/**
* Serializes a cookie name/value pair with options into a Set-Cookie header string
* @param {string} name - Cookie name
* @param {string} value - Cookie value
* @param {Object} [opts={}] - Cookie options
* @param {string} [opts.domain] - Cookie domain
* @param {string} [opts.path] - Cookie path
* @param {number} [opts.maxAge] - Max age in milliseconds
* @param {Date} [opts.expires] - Expiration date
* @param {boolean} [opts.httpOnly] - HttpOnly flag
* @param {boolean} [opts.secure] - Secure flag
* @param {string} [opts.sameSite] - SameSite attribute
* @param {boolean|Function} [opts.encode] - Encoding function or false to skip
* @returns {string} Set-Cookie header value
*/
function serializeCookie(name, value, opts = {}) {
let encodeFn;
if (opts.encode === false) {
encodeFn = (v) => v;
} else if (typeof opts.encode === FUNCTION) {
encodeFn = opts.encode;
} else {
encodeFn = encodeURIComponent;
}
let cookieString = `${name}=${encodeFn(value)}`;
const cookieOpts = [];

if (opts.path) {
cookieOpts.push(`Path=${opts.path}`);
} else {
cookieOpts.push("Path=/");
}

if (opts.domain) {
cookieOpts.push(`Domain=${opts.domain}`);
}

if (opts.maxAge != null) {
cookieOpts.push(`Max-Age=${Math.floor(opts.maxAge / INT_1e3)}`);
if (opts.expires) {
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
} else {
cookieOpts.push(`Expires=${new Date(Date.now() + opts.maxAge).toUTCString()}`);
}
} else if (opts.expires) {
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
}

if (opts.httpOnly) {
cookieOpts.push("HttpOnly");
}

if (opts.secure) {
cookieOpts.push("Secure");
}

if (opts.sameSite) {
const sameSite = opts.sameSite;
if (sameSite.toLowerCase() === "strict") {
cookieOpts.push("SameSite=Strict");
} else if (sameSite.toLowerCase() === "lax") {
cookieOpts.push("SameSite=Lax");
} else if (sameSite.toLowerCase() === "none") {
cookieOpts.push("SameSite=None");
} else {
cookieOpts.push(`SameSite=${sameSite}`);
}
}

cookieString += SEMICOLON_SPACE + cookieOpts.join(SEMICOLON_SPACE);
return cookieString;
}

const valid = Object.entries(mimeDb).filter((i) => EXTENSIONS in i[INT_1]),
mimeExtensions = valid.reduce((a, v) => {
const result = Object.assign({ type: v[INT_0] }, v[INT_1]);
Expand Down Expand Up @@ -409,11 +482,24 @@ function pipeable(method, arg) {

/**
* Writes HTTP response headers using writeHead method
* Merges passed headers with existing response headers to preserve
* headers set via res.setHeader/res.header/res.set prior to writing.
* Only passes the third parameter when headers is explicitly defined.
* @param {Object} res - The HTTP response object
* @param {Object} [headers={}] - Headers object to write
* @param {Object} [headers] - Headers object to write (merged with existing)
*/
function writeHead(res, headers = {}) {
res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], headers);
function writeHead(res, headers) {
const mergeable = headers !== null && typeof headers === OBJECT;
if (mergeable) {
const existing = res.getHeaders && typeof res.getHeaders === FUNCTION ? res.getHeaders() : {};
const merged =
existing && typeof existing === OBJECT ? { ...existing, ...headers } : { ...headers };
res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], merged);
} else if (headers !== undefined) {
res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], headers);
} else {
res.writeHead(res.statusCode);
}
}

/**
Expand Down Expand Up @@ -798,6 +884,45 @@ function createStatusHandler(res) {
return (arg = INT_200) => status(res, arg);
}

/**
* Creates cookie setter handler
* @param {Object} res - Response object
* @returns {Function} Cookie handler function
*/
function createCookieHandler(res) {
return (name, value, opts = {}) => {
const cookieValue = serializeCookie(name, value, opts);
let existing = res.getHeader("set-cookie") || [];
if (!Array.isArray(existing)) {
existing = [existing];
}
existing.push(cookieValue);
res.setHeader("set-cookie", existing);
};
}

/**
* Creates cookie clearing handler
* @param {Object} res - Response object
* @returns {Function} Clear cookie handler function
*/
function createClearCookieHandler(res) {
return (name, opts = {}) => {
const clearOpts = {
...opts,
maxAge: INT_0,
expires: new Date(0),
};
const cookieValue = serializeCookie(name, EMPTY, clearOpts);
let existing = res.getHeader("set-cookie") || [];
if (!Array.isArray(existing)) {
existing = [existing];
}
existing.push(cookieValue);
res.setHeader("set-cookie", existing);
};
}

/**
* Checks if request origin is allowed for CORS
* @param {Object} req - Request object
Expand Down Expand Up @@ -2029,9 +2154,9 @@ class Woodland extends node_events.EventEmitter {
if (typeof req.on !== FUNCTION) {
return next();
}
/* node:coverage ignore next 7 */
req.on(EVT_DATA, (chunk) => {
size += chunk.length;
/* node:coverage ignore next 3 */
if (size > maxLimit) {
req.destroy();
res.error(INT_413);
Expand Down Expand Up @@ -2196,6 +2321,8 @@ class Woodland extends node_events.EventEmitter {
res.send = createSendHandler(req, res, this.#onReady.bind(this), this.#onDone.bind(this));
res.set = createSetHandler(res);
res.status = createStatusHandler(res);
res.cookie = createCookieHandler(res);
res.clearCookie = createClearCookieHandler(res);

res.set(headersBatch);
res.on(EVT_CLOSE, () => this.#logger.log(this.#logger.clf(req, res), INFO));
Expand Down Expand Up @@ -2232,12 +2359,14 @@ class Woodland extends node_events.EventEmitter {
* @returns {boolean} True if origin is safe
*/
#isSafeOrigin(origin) {
if (!origin || typeof origin !== STRING) {
if (origin.length > INT_255) {
return false;
}
if (origin.length > INT_255) {
/* node:coverage ignore next 3 */
if (!origin || typeof origin !== STRING) {
return false;
}
/* node:coverage ignore next 3 */
if (CONTROL_CHAR_PATTERN.test(origin)) {
return false;
}
Expand Down
Loading