woodland 22.2.0 → 22.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -22
- package/dist/cli.cjs +1 -1
- package/dist/woodland.cjs +144 -10
- package/dist/woodland.js +145 -11
- package/package.json +3 -3
- package/types/woodland.d.ts +29 -0
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ app.get("/health", (req, res) => {
|
|
|
124
124
|
|
|
125
125
|
```javascript
|
|
126
126
|
const app = woodland({
|
|
127
|
-
|
|
127
|
+
origins: ["https://myapp.com", "http://localhost:3000"],
|
|
128
128
|
});
|
|
129
129
|
// Woodland handles preflight OPTIONS automatically
|
|
130
130
|
```
|
|
@@ -163,14 +163,13 @@ Set `app.error` to intercept all unhandled errors before the error middleware ch
|
|
|
163
163
|
const app = woodland();
|
|
164
164
|
|
|
165
165
|
app.error = (err, _req, res) => {
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
console.error("Unhandled error:", err);
|
|
167
|
+
res.status(500).send("Internal server error");
|
|
168
168
|
};
|
|
169
169
|
```
|
|
170
170
|
|
|
171
171
|
The handler receives 3 arguments `(err, req, res)` and must terminate the request itself. When set, the error middleware chain is skipped.
|
|
172
172
|
|
|
173
|
-
|
|
174
173
|
## Configuration
|
|
175
174
|
|
|
176
175
|
```javascript
|
|
@@ -199,20 +198,22 @@ res.redirect("/new-url");
|
|
|
199
198
|
res.header("x-custom", "value");
|
|
200
199
|
res.status(201);
|
|
201
200
|
res.error(404);
|
|
201
|
+
res.cookie("session", "abc123", { httpOnly: true, maxAge: 3600000 });
|
|
202
|
+
res.clearCookie("session");
|
|
202
203
|
```
|
|
203
204
|
|
|
204
205
|
## Request Properties
|
|
205
206
|
|
|
206
207
|
```javascript
|
|
207
|
-
req.ip;
|
|
208
|
-
req.params;
|
|
209
|
-
req.parsed;
|
|
210
|
-
req.allow;
|
|
211
|
-
req.cors;
|
|
212
|
-
req.body;
|
|
213
|
-
req.host;
|
|
214
|
-
req.valid;
|
|
215
|
-
req.app;
|
|
208
|
+
req.ip; // Client IP address
|
|
209
|
+
req.params; // URL parameters { id: "123" }
|
|
210
|
+
req.parsed; // URL object
|
|
211
|
+
req.allow; // Allowed methods
|
|
212
|
+
req.cors; // CORS enabled
|
|
213
|
+
req.body; // Request body
|
|
214
|
+
req.host; // Hostname
|
|
215
|
+
req.valid; // Request validity
|
|
216
|
+
req.app; // Woodland application instance (provides access to app.error)
|
|
216
217
|
```
|
|
217
218
|
|
|
218
219
|
## Event Handlers
|
|
@@ -270,7 +271,7 @@ npx woodland --ip=0.0.0.0
|
|
|
270
271
|
## Testing
|
|
271
272
|
|
|
272
273
|
```bash
|
|
273
|
-
npm test # Run tests (
|
|
274
|
+
npm test # Run tests (365 tests, 99.45% line, 98.82% function coverage)
|
|
274
275
|
npm run coverage # Generate coverage report
|
|
275
276
|
npm run benchmark # Performance benchmarks
|
|
276
277
|
npm run lint # Check linting
|
|
@@ -279,7 +280,7 @@ npm run lint # Check linting
|
|
|
279
280
|
## Documentation
|
|
280
281
|
|
|
281
282
|
- [API Reference](docs/API.md) - Complete method documentation
|
|
282
|
-
- [
|
|
283
|
+
- [Framework Overview](docs/OVERVIEW.md) - Architecture, OWASP security, internals
|
|
283
284
|
- [Code Style Guide](docs/CODE_STYLE_GUIDE.md) - Conventions and best practices
|
|
284
285
|
- [Benchmarks](docs/BENCHMARKS.md) - Performance testing results
|
|
285
286
|
|
|
@@ -287,11 +288,11 @@ npm run lint # Check linting
|
|
|
287
288
|
|
|
288
289
|
Woodland delivers **enterprise-grade security without sacrificing performance**. Security features add minimal overhead.
|
|
289
290
|
|
|
290
|
-
| Framework
|
|
291
|
-
|
|
292
|
-
| Fastify
|
|
293
|
-
| **Woodland** | **Built-in**
|
|
294
|
-
| Express
|
|
291
|
+
| Framework | Security Approach | Mean Response Time |
|
|
292
|
+
| ------------ | ------------------- | ------------------ |
|
|
293
|
+
| Fastify | Requires plugins | 0.1491ms |
|
|
294
|
+
| **Woodland** | **Built-in** | **0.1866ms** |
|
|
295
|
+
| Express | Requires middleware | 0.1956ms |
|
|
295
296
|
|
|
296
297
|
## Security
|
|
297
298
|
|
|
@@ -314,8 +315,8 @@ import rateLimit from "express-rate-limit";
|
|
|
314
315
|
app.always(helmet());
|
|
315
316
|
app.always(
|
|
316
317
|
rateLimit({
|
|
317
|
-
windowMs: 15 * 60 * 1000,
|
|
318
|
-
max: 100,
|
|
318
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
319
|
+
max: 100, // Limit each IP to 100 requests
|
|
319
320
|
standardHeaders: true,
|
|
320
321
|
legacyHeaders: false,
|
|
321
322
|
}),
|
|
@@ -323,6 +324,7 @@ app.always(
|
|
|
323
324
|
```
|
|
324
325
|
|
|
325
326
|
**Security Warning:**
|
|
327
|
+
|
|
326
328
|
> ⚠️ **Production Deployment**: Always use a reverse proxy (nginx, Cloudflare) in production for SSL/TLS termination, DDoS protection, and additional security layers.
|
|
327
329
|
|
|
328
330
|
## License
|
package/dist/cli.cjs
CHANGED
package/dist/woodland.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
|
|
5
5
|
* @license BSD-3-Clause
|
|
6
|
-
* @version 22.
|
|
6
|
+
* @version 22.3.0
|
|
7
7
|
*/
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
@@ -288,6 +288,79 @@ const HTML_ESCAPES = Object.freeze({
|
|
|
288
288
|
"'": "'",
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Serializes a cookie name/value pair with options into a Set-Cookie header string
|
|
293
|
+
* @param {string} name - Cookie name
|
|
294
|
+
* @param {string} value - Cookie value
|
|
295
|
+
* @param {Object} [opts={}] - Cookie options
|
|
296
|
+
* @param {string} [opts.domain] - Cookie domain
|
|
297
|
+
* @param {string} [opts.path] - Cookie path
|
|
298
|
+
* @param {number} [opts.maxAge] - Max age in milliseconds
|
|
299
|
+
* @param {Date} [opts.expires] - Expiration date
|
|
300
|
+
* @param {boolean} [opts.httpOnly] - HttpOnly flag
|
|
301
|
+
* @param {boolean} [opts.secure] - Secure flag
|
|
302
|
+
* @param {string} [opts.sameSite] - SameSite attribute
|
|
303
|
+
* @param {boolean|Function} [opts.encode] - Encoding function or false to skip
|
|
304
|
+
* @returns {string} Set-Cookie header value
|
|
305
|
+
*/
|
|
306
|
+
function serializeCookie(name, value, opts = {}) {
|
|
307
|
+
let encodeFn;
|
|
308
|
+
if (opts.encode === false) {
|
|
309
|
+
encodeFn = (v) => v;
|
|
310
|
+
} else if (typeof opts.encode === FUNCTION) {
|
|
311
|
+
encodeFn = opts.encode;
|
|
312
|
+
} else {
|
|
313
|
+
encodeFn = encodeURIComponent;
|
|
314
|
+
}
|
|
315
|
+
let cookieString = `${name}=${encodeFn(value)}`;
|
|
316
|
+
const cookieOpts = [];
|
|
317
|
+
|
|
318
|
+
if (opts.path) {
|
|
319
|
+
cookieOpts.push(`Path=${opts.path}`);
|
|
320
|
+
} else {
|
|
321
|
+
cookieOpts.push("Path=/");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (opts.domain) {
|
|
325
|
+
cookieOpts.push(`Domain=${opts.domain}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (opts.maxAge != null) {
|
|
329
|
+
cookieOpts.push(`Max-Age=${Math.floor(opts.maxAge / INT_1e3)}`);
|
|
330
|
+
if (opts.expires) {
|
|
331
|
+
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
332
|
+
} else {
|
|
333
|
+
cookieOpts.push(`Expires=${new Date(Date.now() + opts.maxAge).toUTCString()}`);
|
|
334
|
+
}
|
|
335
|
+
} else if (opts.expires) {
|
|
336
|
+
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (opts.httpOnly) {
|
|
340
|
+
cookieOpts.push("HttpOnly");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (opts.secure) {
|
|
344
|
+
cookieOpts.push("Secure");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (opts.sameSite) {
|
|
348
|
+
const sameSite = opts.sameSite;
|
|
349
|
+
if (sameSite.toLowerCase() === "strict") {
|
|
350
|
+
cookieOpts.push("SameSite=Strict");
|
|
351
|
+
} else if (sameSite.toLowerCase() === "lax") {
|
|
352
|
+
cookieOpts.push("SameSite=Lax");
|
|
353
|
+
} else if (sameSite.toLowerCase() === "none") {
|
|
354
|
+
cookieOpts.push("SameSite=None");
|
|
355
|
+
} else {
|
|
356
|
+
cookieOpts.push(`SameSite=${sameSite}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
cookieString += SEMICOLON_SPACE + cookieOpts.join(SEMICOLON_SPACE);
|
|
361
|
+
return cookieString;
|
|
362
|
+
}
|
|
363
|
+
|
|
291
364
|
const valid = Object.entries(mimeDb).filter((i) => EXTENSIONS in i[INT_1]),
|
|
292
365
|
mimeExtensions = valid.reduce((a, v) => {
|
|
293
366
|
const result = Object.assign({ type: v[INT_0] }, v[INT_1]);
|
|
@@ -409,11 +482,24 @@ function pipeable(method, arg) {
|
|
|
409
482
|
|
|
410
483
|
/**
|
|
411
484
|
* Writes HTTP response headers using writeHead method
|
|
485
|
+
* Merges passed headers with existing response headers to preserve
|
|
486
|
+
* headers set via res.setHeader/res.header/res.set prior to writing.
|
|
487
|
+
* Only passes the third parameter when headers is explicitly defined.
|
|
412
488
|
* @param {Object} res - The HTTP response object
|
|
413
|
-
* @param {Object} [headers
|
|
489
|
+
* @param {Object} [headers] - Headers object to write (merged with existing)
|
|
414
490
|
*/
|
|
415
|
-
function writeHead(res, headers
|
|
416
|
-
|
|
491
|
+
function writeHead(res, headers) {
|
|
492
|
+
const mergeable = headers !== null && typeof headers === OBJECT;
|
|
493
|
+
if (mergeable) {
|
|
494
|
+
const existing = res.getHeaders && typeof res.getHeaders === FUNCTION ? res.getHeaders() : {};
|
|
495
|
+
const merged =
|
|
496
|
+
existing && typeof existing === OBJECT ? { ...existing, ...headers } : { ...headers };
|
|
497
|
+
res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], merged);
|
|
498
|
+
} else if (headers !== undefined) {
|
|
499
|
+
res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], headers);
|
|
500
|
+
} else {
|
|
501
|
+
res.writeHead(res.statusCode);
|
|
502
|
+
}
|
|
417
503
|
}
|
|
418
504
|
|
|
419
505
|
/**
|
|
@@ -798,6 +884,45 @@ function createStatusHandler(res) {
|
|
|
798
884
|
return (arg = INT_200) => status(res, arg);
|
|
799
885
|
}
|
|
800
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Creates cookie setter handler
|
|
889
|
+
* @param {Object} res - Response object
|
|
890
|
+
* @returns {Function} Cookie handler function
|
|
891
|
+
*/
|
|
892
|
+
function createCookieHandler(res) {
|
|
893
|
+
return (name, value, opts = {}) => {
|
|
894
|
+
const cookieValue = serializeCookie(name, value, opts);
|
|
895
|
+
let existing = res.getHeader("set-cookie") || [];
|
|
896
|
+
if (!Array.isArray(existing)) {
|
|
897
|
+
existing = [existing];
|
|
898
|
+
}
|
|
899
|
+
existing.push(cookieValue);
|
|
900
|
+
res.setHeader("set-cookie", existing);
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Creates cookie clearing handler
|
|
906
|
+
* @param {Object} res - Response object
|
|
907
|
+
* @returns {Function} Clear cookie handler function
|
|
908
|
+
*/
|
|
909
|
+
function createClearCookieHandler(res) {
|
|
910
|
+
return (name, opts = {}) => {
|
|
911
|
+
const clearOpts = {
|
|
912
|
+
...opts,
|
|
913
|
+
maxAge: INT_0,
|
|
914
|
+
expires: new Date(0),
|
|
915
|
+
};
|
|
916
|
+
const cookieValue = serializeCookie(name, EMPTY, clearOpts);
|
|
917
|
+
let existing = res.getHeader("set-cookie") || [];
|
|
918
|
+
if (!Array.isArray(existing)) {
|
|
919
|
+
existing = [existing];
|
|
920
|
+
}
|
|
921
|
+
existing.push(cookieValue);
|
|
922
|
+
res.setHeader("set-cookie", existing);
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
801
926
|
/**
|
|
802
927
|
* Checks if request origin is allowed for CORS
|
|
803
928
|
* @param {Object} req - Request object
|
|
@@ -1476,10 +1601,15 @@ function validateLogging(logging = {}) {
|
|
|
1476
1601
|
const envLogFormat = process.env.WOODLAND_LOG_FORMAT;
|
|
1477
1602
|
const envLogLevel = process.env.WOODLAND_LOG_LEVEL;
|
|
1478
1603
|
|
|
1479
|
-
const enabled =
|
|
1604
|
+
const enabled =
|
|
1605
|
+
envLogEnabled !== undefined
|
|
1606
|
+
? envLogEnabled !== FALSE
|
|
1607
|
+
: logging.enabled !== undefined
|
|
1608
|
+
? logging.enabled !== false
|
|
1609
|
+
: TRUE !== FALSE;
|
|
1480
1610
|
|
|
1481
|
-
const format = resolveLoggingValue(logging.format,
|
|
1482
|
-
const level = resolveLoggingValue(logging.level,
|
|
1611
|
+
const format = resolveLoggingValue(envLogFormat, logging.format, LOG_FORMAT);
|
|
1612
|
+
const level = resolveLoggingValue(envLogLevel, logging.level, INFO);
|
|
1483
1613
|
|
|
1484
1614
|
if (!VALID_LOG_LEVELS.has(level)) {
|
|
1485
1615
|
return Object.freeze({ enabled, format, level: INFO });
|
|
@@ -2024,9 +2154,9 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2024
2154
|
if (typeof req.on !== FUNCTION) {
|
|
2025
2155
|
return next();
|
|
2026
2156
|
}
|
|
2157
|
+
/* node:coverage ignore next 7 */
|
|
2027
2158
|
req.on(EVT_DATA, (chunk) => {
|
|
2028
2159
|
size += chunk.length;
|
|
2029
|
-
/* node:coverage ignore next 3 */
|
|
2030
2160
|
if (size > maxLimit) {
|
|
2031
2161
|
req.destroy();
|
|
2032
2162
|
res.error(INT_413);
|
|
@@ -2191,6 +2321,8 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2191
2321
|
res.send = createSendHandler(req, res, this.#onReady.bind(this), this.#onDone.bind(this));
|
|
2192
2322
|
res.set = createSetHandler(res);
|
|
2193
2323
|
res.status = createStatusHandler(res);
|
|
2324
|
+
res.cookie = createCookieHandler(res);
|
|
2325
|
+
res.clearCookie = createClearCookieHandler(res);
|
|
2194
2326
|
|
|
2195
2327
|
res.set(headersBatch);
|
|
2196
2328
|
res.on(EVT_CLOSE, () => this.#logger.log(this.#logger.clf(req, res), INFO));
|
|
@@ -2227,12 +2359,14 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2227
2359
|
* @returns {boolean} True if origin is safe
|
|
2228
2360
|
*/
|
|
2229
2361
|
#isSafeOrigin(origin) {
|
|
2230
|
-
if (
|
|
2362
|
+
if (origin.length > INT_255) {
|
|
2231
2363
|
return false;
|
|
2232
2364
|
}
|
|
2233
|
-
|
|
2365
|
+
/* node:coverage ignore next 3 */
|
|
2366
|
+
if (!origin || typeof origin !== STRING) {
|
|
2234
2367
|
return false;
|
|
2235
2368
|
}
|
|
2369
|
+
/* node:coverage ignore next 3 */
|
|
2236
2370
|
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2237
2371
|
return false;
|
|
2238
2372
|
}
|
package/dist/woodland.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
|
|
5
5
|
* @license BSD-3-Clause
|
|
6
|
-
* @version 22.
|
|
6
|
+
* @version 22.3.0
|
|
7
7
|
*/
|
|
8
8
|
import {STATUS_CODES}from'node:http';import {EventEmitter}from'node:events';import {readFileSync,createReadStream}from'node:fs';import {etag}from'tiny-etag';import {lru}from'tiny-lru';import {precise}from'precise';import {createRequire}from'node:module';import {join,extname,resolve,sep}from'node:path';import {fileURLToPath,URL as URL$1}from'node:url';import mimeDb from'mime-db';import {coerce}from'tiny-coerce';import {Validator}from'jsonschema';import {realpath,stat,readdir}from'node:fs/promises';const __dirname$2 = fileURLToPath(new URL$1(".", import.meta.url));
|
|
9
9
|
const require$1 = createRequire(import.meta.url);
|
|
@@ -269,7 +269,80 @@ const HTML_ESCAPES = Object.freeze({
|
|
|
269
269
|
">": ">",
|
|
270
270
|
'"': """,
|
|
271
271
|
"'": "'",
|
|
272
|
-
})
|
|
272
|
+
});/**
|
|
273
|
+
* Serializes a cookie name/value pair with options into a Set-Cookie header string
|
|
274
|
+
* @param {string} name - Cookie name
|
|
275
|
+
* @param {string} value - Cookie value
|
|
276
|
+
* @param {Object} [opts={}] - Cookie options
|
|
277
|
+
* @param {string} [opts.domain] - Cookie domain
|
|
278
|
+
* @param {string} [opts.path] - Cookie path
|
|
279
|
+
* @param {number} [opts.maxAge] - Max age in milliseconds
|
|
280
|
+
* @param {Date} [opts.expires] - Expiration date
|
|
281
|
+
* @param {boolean} [opts.httpOnly] - HttpOnly flag
|
|
282
|
+
* @param {boolean} [opts.secure] - Secure flag
|
|
283
|
+
* @param {string} [opts.sameSite] - SameSite attribute
|
|
284
|
+
* @param {boolean|Function} [opts.encode] - Encoding function or false to skip
|
|
285
|
+
* @returns {string} Set-Cookie header value
|
|
286
|
+
*/
|
|
287
|
+
function serializeCookie(name, value, opts = {}) {
|
|
288
|
+
let encodeFn;
|
|
289
|
+
if (opts.encode === false) {
|
|
290
|
+
encodeFn = (v) => v;
|
|
291
|
+
} else if (typeof opts.encode === FUNCTION) {
|
|
292
|
+
encodeFn = opts.encode;
|
|
293
|
+
} else {
|
|
294
|
+
encodeFn = encodeURIComponent;
|
|
295
|
+
}
|
|
296
|
+
let cookieString = `${name}=${encodeFn(value)}`;
|
|
297
|
+
const cookieOpts = [];
|
|
298
|
+
|
|
299
|
+
if (opts.path) {
|
|
300
|
+
cookieOpts.push(`Path=${opts.path}`);
|
|
301
|
+
} else {
|
|
302
|
+
cookieOpts.push("Path=/");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (opts.domain) {
|
|
306
|
+
cookieOpts.push(`Domain=${opts.domain}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (opts.maxAge != null) {
|
|
310
|
+
cookieOpts.push(`Max-Age=${Math.floor(opts.maxAge / INT_1e3)}`);
|
|
311
|
+
if (opts.expires) {
|
|
312
|
+
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
313
|
+
} else {
|
|
314
|
+
cookieOpts.push(`Expires=${new Date(Date.now() + opts.maxAge).toUTCString()}`);
|
|
315
|
+
}
|
|
316
|
+
} else if (opts.expires) {
|
|
317
|
+
cookieOpts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (opts.httpOnly) {
|
|
321
|
+
cookieOpts.push("HttpOnly");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (opts.secure) {
|
|
325
|
+
cookieOpts.push("Secure");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (opts.sameSite) {
|
|
329
|
+
const sameSite = opts.sameSite;
|
|
330
|
+
if (sameSite.toLowerCase() === "strict") {
|
|
331
|
+
cookieOpts.push("SameSite=Strict");
|
|
332
|
+
} else if (sameSite.toLowerCase() === "lax") {
|
|
333
|
+
cookieOpts.push("SameSite=Lax");
|
|
334
|
+
} else if (sameSite.toLowerCase() === "none") {
|
|
335
|
+
cookieOpts.push("SameSite=None");
|
|
336
|
+
} else {
|
|
337
|
+
cookieOpts.push(`SameSite=${sameSite}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
cookieString += SEMICOLON_SPACE + cookieOpts.join(SEMICOLON_SPACE);
|
|
342
|
+
return cookieString;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const valid = Object.entries(mimeDb).filter((i) => EXTENSIONS in i[INT_1]),
|
|
273
346
|
mimeExtensions = valid.reduce((a, v) => {
|
|
274
347
|
const result = Object.assign({ type: v[INT_0] }, v[INT_1]);
|
|
275
348
|
const extCount = result.extensions.length;
|
|
@@ -390,11 +463,24 @@ function pipeable(method, arg) {
|
|
|
390
463
|
|
|
391
464
|
/**
|
|
392
465
|
* Writes HTTP response headers using writeHead method
|
|
466
|
+
* Merges passed headers with existing response headers to preserve
|
|
467
|
+
* headers set via res.setHeader/res.header/res.set prior to writing.
|
|
468
|
+
* Only passes the third parameter when headers is explicitly defined.
|
|
393
469
|
* @param {Object} res - The HTTP response object
|
|
394
|
-
* @param {Object} [headers
|
|
470
|
+
* @param {Object} [headers] - Headers object to write (merged with existing)
|
|
395
471
|
*/
|
|
396
|
-
function writeHead(res, headers
|
|
397
|
-
|
|
472
|
+
function writeHead(res, headers) {
|
|
473
|
+
const mergeable = headers !== null && typeof headers === OBJECT;
|
|
474
|
+
if (mergeable) {
|
|
475
|
+
const existing = res.getHeaders && typeof res.getHeaders === FUNCTION ? res.getHeaders() : {};
|
|
476
|
+
const merged =
|
|
477
|
+
existing && typeof existing === OBJECT ? { ...existing, ...headers } : { ...headers };
|
|
478
|
+
res.writeHead(res.statusCode, STATUS_CODES[res.statusCode], merged);
|
|
479
|
+
} else if (headers !== undefined) {
|
|
480
|
+
res.writeHead(res.statusCode, STATUS_CODES[res.statusCode], headers);
|
|
481
|
+
} else {
|
|
482
|
+
res.writeHead(res.statusCode);
|
|
483
|
+
}
|
|
398
484
|
}
|
|
399
485
|
|
|
400
486
|
/**
|
|
@@ -777,6 +863,45 @@ function createSetHandler(res) {
|
|
|
777
863
|
*/
|
|
778
864
|
function createStatusHandler(res) {
|
|
779
865
|
return (arg = INT_200) => status(res, arg);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Creates cookie setter handler
|
|
870
|
+
* @param {Object} res - Response object
|
|
871
|
+
* @returns {Function} Cookie handler function
|
|
872
|
+
*/
|
|
873
|
+
function createCookieHandler(res) {
|
|
874
|
+
return (name, value, opts = {}) => {
|
|
875
|
+
const cookieValue = serializeCookie(name, value, opts);
|
|
876
|
+
let existing = res.getHeader("set-cookie") || [];
|
|
877
|
+
if (!Array.isArray(existing)) {
|
|
878
|
+
existing = [existing];
|
|
879
|
+
}
|
|
880
|
+
existing.push(cookieValue);
|
|
881
|
+
res.setHeader("set-cookie", existing);
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Creates cookie clearing handler
|
|
887
|
+
* @param {Object} res - Response object
|
|
888
|
+
* @returns {Function} Clear cookie handler function
|
|
889
|
+
*/
|
|
890
|
+
function createClearCookieHandler(res) {
|
|
891
|
+
return (name, opts = {}) => {
|
|
892
|
+
const clearOpts = {
|
|
893
|
+
...opts,
|
|
894
|
+
maxAge: INT_0,
|
|
895
|
+
expires: new Date(0),
|
|
896
|
+
};
|
|
897
|
+
const cookieValue = serializeCookie(name, EMPTY, clearOpts);
|
|
898
|
+
let existing = res.getHeader("set-cookie") || [];
|
|
899
|
+
if (!Array.isArray(existing)) {
|
|
900
|
+
existing = [existing];
|
|
901
|
+
}
|
|
902
|
+
existing.push(cookieValue);
|
|
903
|
+
res.setHeader("set-cookie", existing);
|
|
904
|
+
};
|
|
780
905
|
}/**
|
|
781
906
|
* Checks if request origin is allowed for CORS
|
|
782
907
|
* @param {Object} req - Request object
|
|
@@ -1451,10 +1576,15 @@ function validateLogging(logging = {}) {
|
|
|
1451
1576
|
const envLogFormat = process.env.WOODLAND_LOG_FORMAT;
|
|
1452
1577
|
const envLogLevel = process.env.WOODLAND_LOG_LEVEL;
|
|
1453
1578
|
|
|
1454
|
-
const enabled =
|
|
1579
|
+
const enabled =
|
|
1580
|
+
envLogEnabled !== undefined
|
|
1581
|
+
? envLogEnabled !== FALSE
|
|
1582
|
+
: logging.enabled !== undefined
|
|
1583
|
+
? logging.enabled !== false
|
|
1584
|
+
: TRUE !== FALSE;
|
|
1455
1585
|
|
|
1456
|
-
const format = resolveLoggingValue(logging.format,
|
|
1457
|
-
const level = resolveLoggingValue(logging.level,
|
|
1586
|
+
const format = resolveLoggingValue(envLogFormat, logging.format, LOG_FORMAT);
|
|
1587
|
+
const level = resolveLoggingValue(envLogLevel, logging.level, INFO);
|
|
1458
1588
|
|
|
1459
1589
|
if (!VALID_LOG_LEVELS.has(level)) {
|
|
1460
1590
|
return Object.freeze({ enabled, format, level: INFO });
|
|
@@ -1993,9 +2123,9 @@ class Woodland extends EventEmitter {
|
|
|
1993
2123
|
if (typeof req.on !== FUNCTION) {
|
|
1994
2124
|
return next();
|
|
1995
2125
|
}
|
|
2126
|
+
/* node:coverage ignore next 7 */
|
|
1996
2127
|
req.on(EVT_DATA, (chunk) => {
|
|
1997
2128
|
size += chunk.length;
|
|
1998
|
-
/* node:coverage ignore next 3 */
|
|
1999
2129
|
if (size > maxLimit) {
|
|
2000
2130
|
req.destroy();
|
|
2001
2131
|
res.error(INT_413);
|
|
@@ -2160,6 +2290,8 @@ class Woodland extends EventEmitter {
|
|
|
2160
2290
|
res.send = createSendHandler(req, res, this.#onReady.bind(this), this.#onDone.bind(this));
|
|
2161
2291
|
res.set = createSetHandler(res);
|
|
2162
2292
|
res.status = createStatusHandler(res);
|
|
2293
|
+
res.cookie = createCookieHandler(res);
|
|
2294
|
+
res.clearCookie = createClearCookieHandler(res);
|
|
2163
2295
|
|
|
2164
2296
|
res.set(headersBatch);
|
|
2165
2297
|
res.on(EVT_CLOSE, () => this.#logger.log(this.#logger.clf(req, res), INFO));
|
|
@@ -2196,12 +2328,14 @@ class Woodland extends EventEmitter {
|
|
|
2196
2328
|
* @returns {boolean} True if origin is safe
|
|
2197
2329
|
*/
|
|
2198
2330
|
#isSafeOrigin(origin) {
|
|
2199
|
-
if (
|
|
2331
|
+
if (origin.length > INT_255) {
|
|
2200
2332
|
return false;
|
|
2201
2333
|
}
|
|
2202
|
-
|
|
2334
|
+
/* node:coverage ignore next 3 */
|
|
2335
|
+
if (!origin || typeof origin !== STRING) {
|
|
2203
2336
|
return false;
|
|
2204
2337
|
}
|
|
2338
|
+
/* node:coverage ignore next 3 */
|
|
2205
2339
|
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2206
2340
|
return false;
|
|
2207
2341
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "woodland",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.3.0",
|
|
4
4
|
"description": "Secure HTTP framework for Node.js. Express-compatible with built-in security, no performance tradeoff.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"prebuild": "npm run clean",
|
|
59
59
|
"rollup": "rollup --config",
|
|
60
60
|
"sample": "node sample.js",
|
|
61
|
-
"test": "npm run lint && node --test tests/**/*.test.js",
|
|
61
|
+
"test": "npm run lint && WOODLAND_LOG_ENABLED=false node --test tests/**/*.test.js",
|
|
62
62
|
"test:cli": "node --test tests/unit/cli.test.js",
|
|
63
63
|
"test:watch": "node --test --watch tests/**/*.test.js"
|
|
64
64
|
},
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"auto-changelog": "^2.5.0",
|
|
75
75
|
"husky": "^9.1.7",
|
|
76
|
-
"oxfmt": "^0.
|
|
76
|
+
"oxfmt": "^0.51.0",
|
|
77
77
|
"oxlint": "^1.57.0",
|
|
78
78
|
"rimraf": "^6.1.3",
|
|
79
79
|
"rollup": "^4.60.0"
|
package/types/woodland.d.ts
CHANGED
|
@@ -37,6 +37,35 @@ export interface RouteInfo {
|
|
|
37
37
|
visible: number;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export interface CookieOptions {
|
|
41
|
+
domain?: string;
|
|
42
|
+
path?: string;
|
|
43
|
+
maxAge?: number;
|
|
44
|
+
expires?: Date;
|
|
45
|
+
httpOnly?: boolean;
|
|
46
|
+
secure?: boolean;
|
|
47
|
+
sameSite?: "strict" | "lax" | "none" | string;
|
|
48
|
+
encode?: boolean | ((value: string) => string);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ClearCookieOptions {
|
|
52
|
+
domain?: string;
|
|
53
|
+
path?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WoodlandResponse {
|
|
57
|
+
locals: Record<string, any>;
|
|
58
|
+
error(status?: number, body?: Error | string): void;
|
|
59
|
+
header(name: string, value: string | number): void;
|
|
60
|
+
json(body: any, status?: number, headers?: Record<string, string>): void;
|
|
61
|
+
redirect(uri: string, perm?: boolean): void;
|
|
62
|
+
send(body?: any, status?: number, headers?: Record<string, string>): void;
|
|
63
|
+
set(headers: Record<string, string>): void;
|
|
64
|
+
status(code: number): void;
|
|
65
|
+
cookie(name: string, value: string, opts?: CookieOptions): void;
|
|
66
|
+
clearCookie(name: string, opts?: ClearCookieOptions): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
export class Woodland extends EventEmitter {
|
|
41
70
|
// Public read-only properties (getters)
|
|
42
71
|
readonly logger: Readonly<{
|