@momsfriendlydevco/cowboy 1.4.1 → 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
@@ -10,6 +10,7 @@ Features:
10
10
  * Built in middleware + request validation via [Joi](https://joi.dev)
11
11
  * Built-in debug support for testkits + Wrangler
12
12
  * Built-in JSON / Multipart (or FormData) / Plain text decoding and population of `req.body`
13
+ * Scheduled tasks can return promises and they are automatically awaited (no need to do `ctx.waitUntil()`)
13
14
 
14
15
 
15
16
  Examples
@@ -194,6 +195,7 @@ This object contains various Express-like utility functions:
194
195
  | `end(data?, end=true)` | Set the output response and optionally end the session |
195
196
  | `sendStatus(code, data?, end=true)` | Send a HTTP response code and optionally end the session |
196
197
  | `status(code)` | Set the HTTP response code |
198
+ | `beforeServe(callback)` | Queue a middleware callback before `toCloudflareResponse()` |
197
199
  | `toCloudflareResponse()` | Return the equivelent CloudflareResponse object |
198
200
 
199
201
  All functions (except `toCloudflareResponse()`) are chainable and return the original `CowboyResponse` instance.
@@ -327,6 +329,12 @@ devOnly()
327
329
  Allow access to the endpoint ONLY if Cloudflare is running in local development mode. Throws a 403 otherwise.
328
330
 
329
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
+
330
338
  validate(key, validator)
331
339
  ------------------------
332
340
  Validate the incoming `req.$KEY` object using [Joyful](https://github.com/MomsFriendlyDevCo/Joyful).
package/lib/cowboy.js CHANGED
@@ -148,10 +148,24 @@ export class Cowboy {
148
148
  if (!this.schedule.handler) return res.status(404).send('No scheduler installed');
149
149
 
150
150
  try {
151
- let result = await this.schedule.handler.call(this, {}, env, {});
152
- res.send(result);
151
+ let result = await this.schedule.handler.call(
152
+ this,
153
+ { // Faked Cloudflare `controller` context
154
+ cron: 'FAKE',
155
+ type: 'scheduled',
156
+ scheduledTime: (new Date()).toISOString(),
157
+ },
158
+ env,
159
+ { // Faked Cloudflare `ctx` context - we provide a fake waitUntil here
160
+ waitUntil() {
161
+ throw new Error('ctx.waitUntil() functionality is provided natively by Cowboy.schedule(cb:Function) - just return a promise instead of using it');
162
+ },
163
+ },
164
+ );
165
+ debug('Got scheduler response', result);
166
+ return res.send(result);
153
167
  } catch (e) {
154
- res.status(400).send(`Scheduler threw error: ${e.toString()}`);
168
+ return res.status(400).send(`Scheduler threw error: ${e.toString()}`);
155
169
  }
156
170
  },
157
171
  ]
@@ -189,7 +203,7 @@ export class Cowboy {
189
203
  )
190
204
  );
191
205
  }
192
- return res.sendStatus(404).toCloudflareResponse(); // No matching route
206
+ return await res.sendStatus(404).toCloudflareResponse(); // No matching route
193
207
  }
194
208
 
195
209
  // Populate params
@@ -204,24 +218,7 @@ export class Cowboy {
204
218
 
205
219
  if (!response) throw new Error('Middleware chain ended without returning a response!');
206
220
  if (!response.toCloudflareResponse) throw new Error('Eventual middleware chain output should have a .toCloudflareResponse() method');
207
- return response.toCloudflareResponse();
208
- }
209
-
210
-
211
- /**
212
- * Set up Cloudflare response to "scheduled" call
213
- * This is really just a map to the last handler we installed to .schedule(cb) - for now
214
- *
215
- * @param {CloudflareEvent} event The Cloudflare event context passed
216
- * @param {Object} env Environment variables
217
- * @param {CloudflareContext} ctx The Cloudflare context to respond to
218
- *
219
- * @returns {Cowboy} This chainable Cowboy router instance
220
- */
221
- scheduled(event, env, ctx) {
222
- if (!this.schedule.handler) throw new Error('Attemped to access Cowboy.scheduled without first calling .schedule() to set something up!');
223
- this.schedule.handler.call(this, event, env, ctx);
224
- return this;
221
+ return await response.toCloudflareResponse();
225
222
  }
226
223
 
227
224
 
@@ -336,12 +333,43 @@ export class Cowboy {
336
333
  * @returns {Cowboy} This chainable Cowboy router instance
337
334
  */
338
335
  schedule(handler) {
339
- console.info('Installed schedule event handler. Access via http://localhost:8787/__scheduled');
336
+ debug('Installed schedule event handler');
340
337
  this.schedule.handler = handler;
341
338
  return this;
342
339
  }
343
340
 
344
341
 
342
+ /**
343
+ * Set up Cloudflare response to "scheduled" call
344
+ * This is really just a map to the last handler we installed to .schedule(cb) - for now
345
+ *
346
+ * @param {CloudflareEvent} event The Cloudflare event context passed
347
+ * @param {Object} env Environment variables
348
+ * @param {CloudflareContext} ctx The Cloudflare context to respond to
349
+ *
350
+ * @returns {Cowboy} This chainable Cowboy router instance
351
+ */
352
+ scheduled(event, env, ctx) {
353
+ if (!this.schedule.handler) throw new Error('Attemped to access Cowboy.scheduled without first calling .schedule() to set something up!');
354
+
355
+ // Wrap all scheduler calls in ctx.waitUntil() so promises are always waited on
356
+ ctx.waitUntil(
357
+ this.schedule.handler.call(
358
+ this,
359
+ event,
360
+ env,
361
+ {
362
+ waitUntil() {
363
+ throw new Error('ctx.waitUntil() functionality is provided natively by Cowboy.schedule(cb:Function) - just return a promise instead of using it');
364
+ },
365
+ },
366
+ )
367
+ );
368
+
369
+ return this;
370
+ }
371
+
372
+
345
373
  /**
346
374
  * Generial Init() sequence
347
375
  * This will be run automatically on setup or the first fetch()
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.4.1",
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"