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 CHANGED
@@ -124,7 +124,7 @@ app.get("/health", (req, res) => {
124
124
 
125
125
  ```javascript
126
126
  const app = woodland({
127
- origins: ["https://myapp.com", "http://localhost:3000"],
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
- console.error("Unhandled error:", err);
167
- res.status(500).send("Internal server error");
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; // Client IP address
208
- req.params; // URL parameters { id: "123" }
209
- req.parsed; // URL object
210
- req.allow; // Allowed methods
211
- req.cors; // CORS enabled
212
- req.body; // Request body
213
- req.host; // Hostname
214
- req.valid; // Request validity
215
- req.app; // Woodland application instance (provides access to app.error)
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 (334 tests, 100% line, 99.37% function, 95.90% branch coverage)
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
- - [Technical Documentation](docs/TECHNICAL_DOCUMENTATION.md) - Architecture, OWASP security, internals
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 | Security Approach | Mean Response Time |
291
- |-----------|------------------|-------------------|
292
- | Fastify | Requires plugins | 0.1491ms |
293
- | **Woodland** | **Built-in** | **0.1866ms** |
294
- | Express | Requires middleware | 0.1956ms |
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, // 15 minutes
318
- max: 100, // Limit each IP to 100 requests
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
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
6
6
  * @license BSD-3-Clause
7
- * @version 22.2.0
7
+ * @version 22.3.0
8
8
  */
9
9
  'use strict';
10
10
 
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.2.0
6
+ * @version 22.3.0
7
7
  */
8
8
  'use strict';
9
9
 
@@ -288,6 +288,79 @@ const HTML_ESCAPES = Object.freeze({
288
288
  "'": "&#39;",
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={}] - Headers object to write
489
+ * @param {Object} [headers] - Headers object to write (merged with existing)
414
490
  */
415
- function writeHead(res, headers = {}) {
416
- res.writeHead(res.statusCode, node_http.STATUS_CODES[res.statusCode], headers);
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 = logging.enabled ?? (envLogEnabled ?? TRUE) !== FALSE;
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, envLogFormat, LOG_FORMAT);
1482
- const level = resolveLoggingValue(logging.level, envLogLevel, INFO);
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 (!origin || typeof origin !== STRING) {
2362
+ if (origin.length > INT_255) {
2231
2363
  return false;
2232
2364
  }
2233
- if (origin.length > INT_255) {
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.2.0
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
  ">": "&gt;",
270
270
  '"': "&quot;",
271
271
  "'": "&#39;",
272
- });const valid = Object.entries(mimeDb).filter((i) => EXTENSIONS in i[INT_1]),
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={}] - Headers object to write
470
+ * @param {Object} [headers] - Headers object to write (merged with existing)
395
471
  */
396
- function writeHead(res, headers = {}) {
397
- res.writeHead(res.statusCode, STATUS_CODES[res.statusCode], headers);
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 = logging.enabled ?? (envLogEnabled ?? TRUE) !== FALSE;
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, envLogFormat, LOG_FORMAT);
1457
- const level = resolveLoggingValue(logging.level, envLogLevel, INFO);
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 (!origin || typeof origin !== STRING) {
2331
+ if (origin.length > INT_255) {
2200
2332
  return false;
2201
2333
  }
2202
- if (origin.length > INT_255) {
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.2.0",
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.49.0",
76
+ "oxfmt": "^0.51.0",
77
77
  "oxlint": "^1.57.0",
78
78
  "rimraf": "^6.1.3",
79
79
  "rollup": "^4.60.0"
@@ -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<{