@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 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
- if (!this.body)
94
- this.body = this.code >= 200 && this.code <= 299
95
- ? 'ok' // Set body payload if we don't already have one
96
- : `${this.code}: Fail`
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
- let cfOptions = {
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.substr(0, 50) + '…' // eslint-disable-line unicorn/prefer-string-slice
151
+ typeof this.body == 'string' && this.body.length > 30 ? this.body.slice(0, 50) + '…'
130
152
  : this.body,
131
153
  }, null, '\t'));
132
- return new this.CloudflareResponse(this.body, cfOptions);
154
+
155
+ return new this.CloudflareResponse(this.body, {
156
+ status: this.code,
157
+ headers: this.headers,
158
+ });
133
159
  }
134
160
  }
@@ -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
+ }
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momsfriendlydevco/cowboy",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Wrapper around Cloudflare Wrangler to provide a more Express-like experience",
5
5
  "scripts": {
6
6
  "lint": "eslint"