woodland 22.2.1 → 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 +23 -21
- package/dist/cli.cjs +1 -1
- package/dist/woodland.cjs +136 -7
- package/dist/woodland.js +137 -8
- package/package.json +1 -1
- 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
|
|
@@ -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
|
|
@@ -2029,9 +2154,9 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2029
2154
|
if (typeof req.on !== FUNCTION) {
|
|
2030
2155
|
return next();
|
|
2031
2156
|
}
|
|
2157
|
+
/* node:coverage ignore next 7 */
|
|
2032
2158
|
req.on(EVT_DATA, (chunk) => {
|
|
2033
2159
|
size += chunk.length;
|
|
2034
|
-
/* node:coverage ignore next 3 */
|
|
2035
2160
|
if (size > maxLimit) {
|
|
2036
2161
|
req.destroy();
|
|
2037
2162
|
res.error(INT_413);
|
|
@@ -2196,6 +2321,8 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2196
2321
|
res.send = createSendHandler(req, res, this.#onReady.bind(this), this.#onDone.bind(this));
|
|
2197
2322
|
res.set = createSetHandler(res);
|
|
2198
2323
|
res.status = createStatusHandler(res);
|
|
2324
|
+
res.cookie = createCookieHandler(res);
|
|
2325
|
+
res.clearCookie = createClearCookieHandler(res);
|
|
2199
2326
|
|
|
2200
2327
|
res.set(headersBatch);
|
|
2201
2328
|
res.on(EVT_CLOSE, () => this.#logger.log(this.#logger.clf(req, res), INFO));
|
|
@@ -2232,12 +2359,14 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2232
2359
|
* @returns {boolean} True if origin is safe
|
|
2233
2360
|
*/
|
|
2234
2361
|
#isSafeOrigin(origin) {
|
|
2235
|
-
if (
|
|
2362
|
+
if (origin.length > INT_255) {
|
|
2236
2363
|
return false;
|
|
2237
2364
|
}
|
|
2238
|
-
|
|
2365
|
+
/* node:coverage ignore next 3 */
|
|
2366
|
+
if (!origin || typeof origin !== STRING) {
|
|
2239
2367
|
return false;
|
|
2240
2368
|
}
|
|
2369
|
+
/* node:coverage ignore next 3 */
|
|
2241
2370
|
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2242
2371
|
return false;
|
|
2243
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
|
|
@@ -1998,9 +2123,9 @@ class Woodland extends EventEmitter {
|
|
|
1998
2123
|
if (typeof req.on !== FUNCTION) {
|
|
1999
2124
|
return next();
|
|
2000
2125
|
}
|
|
2126
|
+
/* node:coverage ignore next 7 */
|
|
2001
2127
|
req.on(EVT_DATA, (chunk) => {
|
|
2002
2128
|
size += chunk.length;
|
|
2003
|
-
/* node:coverage ignore next 3 */
|
|
2004
2129
|
if (size > maxLimit) {
|
|
2005
2130
|
req.destroy();
|
|
2006
2131
|
res.error(INT_413);
|
|
@@ -2165,6 +2290,8 @@ class Woodland extends EventEmitter {
|
|
|
2165
2290
|
res.send = createSendHandler(req, res, this.#onReady.bind(this), this.#onDone.bind(this));
|
|
2166
2291
|
res.set = createSetHandler(res);
|
|
2167
2292
|
res.status = createStatusHandler(res);
|
|
2293
|
+
res.cookie = createCookieHandler(res);
|
|
2294
|
+
res.clearCookie = createClearCookieHandler(res);
|
|
2168
2295
|
|
|
2169
2296
|
res.set(headersBatch);
|
|
2170
2297
|
res.on(EVT_CLOSE, () => this.#logger.log(this.#logger.clf(req, res), INFO));
|
|
@@ -2201,12 +2328,14 @@ class Woodland extends EventEmitter {
|
|
|
2201
2328
|
* @returns {boolean} True if origin is safe
|
|
2202
2329
|
*/
|
|
2203
2330
|
#isSafeOrigin(origin) {
|
|
2204
|
-
if (
|
|
2331
|
+
if (origin.length > INT_255) {
|
|
2205
2332
|
return false;
|
|
2206
2333
|
}
|
|
2207
|
-
|
|
2334
|
+
/* node:coverage ignore next 3 */
|
|
2335
|
+
if (!origin || typeof origin !== STRING) {
|
|
2208
2336
|
return false;
|
|
2209
2337
|
}
|
|
2338
|
+
/* node:coverage ignore next 3 */
|
|
2210
2339
|
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2211
2340
|
return false;
|
|
2212
2341
|
}
|
package/package.json
CHANGED
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<{
|