@momsfriendlydevco/cowboy 1.5.0 → 1.6.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 +7 -0
- package/lib/cowboy.js +2 -2
- package/lib/response.js +37 -11
- package/middleware/cors.js +6 -2
- package/middleware/etagCaching.js +120 -0
- package/middleware/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -195,6 +195,7 @@ This object contains various Express-like utility functions:
|
|
|
195
195
|
| `end(data?, end=true)` | Set the output response and optionally end the session |
|
|
196
196
|
| `sendStatus(code, data?, end=true)` | Send a HTTP response code and optionally end the session |
|
|
197
197
|
| `status(code)` | Set the HTTP response code |
|
|
198
|
+
| `beforeServe(callback)` | Queue a middleware callback before `toCloudflareResponse()` |
|
|
198
199
|
| `toCloudflareResponse()` | Return the equivelent CloudflareResponse object |
|
|
199
200
|
|
|
200
201
|
All functions (except `toCloudflareResponse()`) are chainable and return the original `CowboyResponse` instance.
|
|
@@ -328,6 +329,12 @@ devOnly()
|
|
|
328
329
|
Allow access to the endpoint ONLY if Cloudflare is running in local development mode. Throws a 403 otherwise.
|
|
329
330
|
|
|
330
331
|
|
|
332
|
+
etagCaching(options)
|
|
333
|
+
--------------------
|
|
334
|
+
Return a `ETag` header with every response return 304 responses if the `If-None-Match` header is the same value.
|
|
335
|
+
This significantly reduces bandwidth if the same output result would be returned should the client include that header.
|
|
336
|
+
|
|
337
|
+
|
|
331
338
|
validate(key, validator)
|
|
332
339
|
------------------------
|
|
333
340
|
Validate the incoming `req.$KEY` object using [Joyful](https://github.com/MomsFriendlyDevCo/Joyful).
|
package/lib/cowboy.js
CHANGED
|
@@ -203,7 +203,7 @@ export class Cowboy {
|
|
|
203
203
|
)
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
|
-
return res.sendStatus(404).toCloudflareResponse(); // No matching route
|
|
206
|
+
return await res.sendStatus(404).toCloudflareResponse(); // No matching route
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
// Populate params
|
|
@@ -218,7 +218,7 @@ export class Cowboy {
|
|
|
218
218
|
|
|
219
219
|
if (!response) throw new Error('Middleware chain ended without returning a response!');
|
|
220
220
|
if (!response.toCloudflareResponse) throw new Error('Eventual middleware chain output should have a .toCloudflareResponse() method');
|
|
221
|
-
return response.toCloudflareResponse();
|
|
221
|
+
return await response.toCloudflareResponse();
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
|
package/lib/response.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import debug from '#lib/debug';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Generic all-in-one response wrapper to mangle responses without having to memorize all the weird syntax that Wrangler / Cloudflare workers need
|
|
3
5
|
*/
|
|
@@ -6,8 +8,10 @@ export default class CowboyResponse {
|
|
|
6
8
|
code = null;
|
|
7
9
|
headers = {};
|
|
8
10
|
hasSent = false;
|
|
11
|
+
beforeServeCallbacks = [];
|
|
9
12
|
CloudflareResponse = Response;
|
|
10
13
|
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Assign various output headers
|
|
13
17
|
* @param {Object|String} options Either an header object to be merged or the header to set
|
|
@@ -90,10 +94,16 @@ export default class CowboyResponse {
|
|
|
90
94
|
*/
|
|
91
95
|
status(code) {
|
|
92
96
|
this.code = code;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
|
|
98
|
+
// Work out if we should be sending something
|
|
99
|
+
if (code >= 200 && code <= 299) { // OK signal - maybe we should send something
|
|
100
|
+
if (!this.body)
|
|
101
|
+
this.body = this.code >= 200 && this.code <= 299
|
|
102
|
+
? 'ok' // Set body payload if we don't already have one
|
|
103
|
+
: `${this.code}: Fail`
|
|
104
|
+
} else if ((code < 200) || (code >= 300 && code <= 399)) { // Code 1** or 3** - send empty payload
|
|
105
|
+
this.body = null;
|
|
106
|
+
} // Implied else - Hope that the dev has set body correctly
|
|
97
107
|
|
|
98
108
|
return this;
|
|
99
109
|
}
|
|
@@ -114,21 +124,37 @@ export default class CowboyResponse {
|
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Queue up a function to run before calling `toCloudflareResponse()`
|
|
129
|
+
*
|
|
130
|
+
* @param {Function} cb Async function to queue. Will be called as `(res:CowboyResponse)` - use `res.body` to access the body, res can be mutated before being served
|
|
131
|
+
* @returns {CowboyResponse} This chainable instance
|
|
132
|
+
*/
|
|
133
|
+
beforeServe(cb) {
|
|
134
|
+
this.beforeServeCallbacks.push(cb);
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
117
139
|
/**
|
|
118
140
|
* Convert the current CoyboyResponse into a CloudflareResponse object
|
|
119
141
|
* @returns {CloudflareResponse} The cloudflare output object
|
|
120
142
|
*/
|
|
121
|
-
toCloudflareResponse() {
|
|
122
|
-
|
|
143
|
+
async toCloudflareResponse() {
|
|
144
|
+
// Await all beforeServeCallbacks
|
|
145
|
+
await Array.fromAsync(this.beforeServeCallbacks, cb => cb.call(this, this));
|
|
146
|
+
|
|
147
|
+
debug('CF-Response', JSON.stringify({
|
|
123
148
|
status: this.code,
|
|
124
149
|
headers: this.headers,
|
|
125
|
-
};
|
|
126
|
-
console.log('Response', JSON.stringify({
|
|
127
|
-
...cfOptions,
|
|
128
150
|
body:
|
|
129
|
-
typeof this.body == 'string' && this.body.length > 30 ? this.body.
|
|
151
|
+
typeof this.body == 'string' && this.body.length > 30 ? this.body.slice(0, 50) + '…'
|
|
130
152
|
: this.body,
|
|
131
153
|
}, null, '\t'));
|
|
132
|
-
|
|
154
|
+
|
|
155
|
+
return new this.CloudflareResponse(this.body, {
|
|
156
|
+
status: this.code,
|
|
157
|
+
headers: this.headers,
|
|
158
|
+
});
|
|
133
159
|
}
|
|
134
160
|
}
|
package/middleware/cors.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @param {String} [options.origin='*'] Origin URL to allow
|
|
7
7
|
* @param {String} [options.headers='*'] Headers to allow
|
|
8
8
|
* @param {Array<String>} [options.methods=['GET','POST','OPTIONS']] Allowable HTTP methods to add CORS to
|
|
9
|
+
* @param {Boolean} [options.debug=false] Output what endpoints have had CORS automatically attached
|
|
9
10
|
*
|
|
10
11
|
* @returns {CowboyMiddleware}
|
|
11
12
|
*/
|
|
@@ -15,6 +16,7 @@ export default function CowboyMiddlewareCORS(options) {
|
|
|
15
16
|
origin: '*',
|
|
16
17
|
headers: '*',
|
|
17
18
|
methods: ['GET', 'POST', 'OPTIONS'],
|
|
19
|
+
debug: false,
|
|
18
20
|
...options,
|
|
19
21
|
};
|
|
20
22
|
|
|
@@ -32,11 +34,13 @@ export default function CowboyMiddlewareCORS(options) {
|
|
|
32
34
|
req.router.routes
|
|
33
35
|
.filter(route => !route.methods.includes('OPTIONS'))
|
|
34
36
|
.forEach(route =>
|
|
35
|
-
route.paths.forEach(path =>
|
|
37
|
+
route.paths.forEach(path => {
|
|
38
|
+
if (settings.debug) console.log('[Cowboy/CORS middleware] Attach CORS to', path);
|
|
39
|
+
|
|
36
40
|
req.router.options(path, (req, res) =>
|
|
37
41
|
res.sendStatus(200)
|
|
38
42
|
)
|
|
39
|
-
)
|
|
43
|
+
})
|
|
40
44
|
);
|
|
41
45
|
|
|
42
46
|
req.router.loadedCors = true; // Mark we've already done this so we don't keep tweaking the router
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Utility: sortKeys(o) - Re-sort a JSON object so that keys are in a predictable order {{{
|
|
2
|
+
function sortKeys(o) {
|
|
3
|
+
if (typeof o !== 'object' || o === null) return o;
|
|
4
|
+
if (Array.isArray(o)) return o.map(sortKeys);
|
|
5
|
+
return Object.keys(o)
|
|
6
|
+
.sort()
|
|
7
|
+
.reduce((acc, key) => {
|
|
8
|
+
acc[key] = sortKeys(o[key]);
|
|
9
|
+
return acc;
|
|
10
|
+
}, {});
|
|
11
|
+
};
|
|
12
|
+
// }}}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cowboy middleware which will return a 304 if the incoming eTag matches the hash of the last identical response
|
|
17
|
+
* If hitting the same endpoints over and over this can significantly improve response times
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} [options] Additional options to mutate behaviour
|
|
20
|
+
* @param {Function} [options.enabled] Async function to determine if caching should be used. Defaults to truthy only if the request method is 'GET'. Called as `(req:CowboyRequest, settings:Object)` as early in the process as possible
|
|
21
|
+
* @param {Function} [options.payload] Async function to determine what to hash. Defaults to method+query+url+body. Called as `(req:CowboyRequest, res:CowboyResponse, settings:Object)` and expeceted to return a POJO or `false` to disable caching
|
|
22
|
+
* @param {TextEncoder} [options.textEncoder] The TextEncoder instance to use when encoding
|
|
23
|
+
* @param {Function} [options.hasher] Async hashing function, should accept a POJO and return a hash of some variety (defaults to sorting POJO keys and returning an SHA-256). Called as `(obj:Object, settings:Object)`
|
|
24
|
+
* @param {Boolean|Function} [options.debug=false] Enable debug verbosity while working. If true will be converted into a built-in, otherwise specify how debugging should be handled. Called as `(...msg:Any)`
|
|
25
|
+
*
|
|
26
|
+
* @returns {CowboyMiddleware} A CowboyMiddleware compatible function - this can be used on individual requests or globally
|
|
27
|
+
*/
|
|
28
|
+
export default function CowboyEtagCaching(options) {
|
|
29
|
+
let settings = {
|
|
30
|
+
enabled(req, settings) { // eslint-disable-line no-unused-vars
|
|
31
|
+
return (
|
|
32
|
+
req.method == 'GET' // Method must be GET
|
|
33
|
+
&& !req.headers['cache-control'] // No cache control headers
|
|
34
|
+
&& !req.headers['pragma'] // No pragma control headers
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
payload(req, res, settings) { // eslint-disable-line no-unused-vars
|
|
38
|
+
let payload = {
|
|
39
|
+
method: req.method,
|
|
40
|
+
query: req.query,
|
|
41
|
+
url: req.path,
|
|
42
|
+
body: res.body,
|
|
43
|
+
};
|
|
44
|
+
return payload;
|
|
45
|
+
},
|
|
46
|
+
textEncoder: new TextEncoder(),
|
|
47
|
+
async hasher(obj, settings) {
|
|
48
|
+
let text = JSON.stringify(sortKeys(obj));
|
|
49
|
+
let data = settings.textEncoder.encode(text);
|
|
50
|
+
let hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
51
|
+
let hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
52
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
53
|
+
},
|
|
54
|
+
debug: false,
|
|
55
|
+
...options,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Transform settings.debug into a callable
|
|
59
|
+
settings.debug =
|
|
60
|
+
!settings.debug ? ()=> {} // No-op if disabled
|
|
61
|
+
: typeof settings.debug == 'function' ? settings.debug
|
|
62
|
+
: settings.debug === true ? (...msg) => console.log('[CowboyCacheContent]', ...msg)
|
|
63
|
+
: (()=> { throw new Error('Unknown CowboyEtagCaching.debug type') })()
|
|
64
|
+
|
|
65
|
+
return async function(req, res) {
|
|
66
|
+
let isEnabled = await settings.enabled(req, settings);
|
|
67
|
+
|
|
68
|
+
if (!isEnabled) {
|
|
69
|
+
settings.debug('Disabled for request', req.path);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
settings.debug('Enabled for request', req.path);
|
|
74
|
+
|
|
75
|
+
// Queue up interceptor before responding to handle the output
|
|
76
|
+
res.beforeServe(async ()=> {
|
|
77
|
+
if (typeof res.data == 'object') {
|
|
78
|
+
settings.debug('Refusing to cache - res.data is not an object');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let payload = await settings.payload(req, res, settings);
|
|
83
|
+
if (typeof payload != 'object') {
|
|
84
|
+
settings.debug('Refusing to cache - payload() returned non-object');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
settings.debug('Payload keys', Object.keys(payload));
|
|
89
|
+
|
|
90
|
+
let hash = await settings.hasher(payload, settings);
|
|
91
|
+
if (typeof hash != 'string') {
|
|
92
|
+
settings.debug('Refusing to cache - hasher() returned non-string');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
settings.debug('Using ETag hash', hash);
|
|
97
|
+
res.set('ETag', `"${hash}"`);
|
|
98
|
+
|
|
99
|
+
// Incoming hash matcher?
|
|
100
|
+
if (req.headers['if-none-match']) {
|
|
101
|
+
settings.debug('Incoming request has Headers[If-None-Match]:', req.headers['if-none-match']);
|
|
102
|
+
let etagMatcher = new RegExp( // Compute ETag header matcher
|
|
103
|
+
'^'
|
|
104
|
+
+ '(?:W\/)?' // Cloudflare has a tendency to rewrite strong hashes to weak (`"abc"` -> `W/"abc"`)
|
|
105
|
+
+ '"'
|
|
106
|
+
+ hash // We're trusting that the hash is only alpha numeric, god help us
|
|
107
|
+
+ '"'
|
|
108
|
+
+ '$'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (etagMatcher.test(req.headers['if-none-match'])) {
|
|
112
|
+
settings.debug('Request has matching if-none-match header - send 304 response');
|
|
113
|
+
res.sendStatus(304);
|
|
114
|
+
} else {
|
|
115
|
+
settings.debug('Request has NON-matching if-none-match header - send full response');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
}
|
package/middleware/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import cors from '#middleware/cors';
|
|
2
2
|
import devOnly from '#middleware/devOnly';
|
|
3
|
+
import etagCaching from '#middleware/etagCaching';
|
|
3
4
|
import parseJwt from '#middleware/parseJwt';
|
|
4
5
|
import validate from '#middleware/validate';
|
|
5
6
|
import validateBody from '#middleware/validateBody';
|
|
@@ -10,6 +11,7 @@ import validateQuery from '#middleware/validateQuery';
|
|
|
10
11
|
export default {
|
|
11
12
|
cors,
|
|
12
13
|
devOnly,
|
|
14
|
+
etagCaching,
|
|
13
15
|
parseJwt,
|
|
14
16
|
validate,
|
|
15
17
|
validateBody,
|