@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 +8 -0
- package/lib/cowboy.js +51 -23
- 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
|
@@ -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(
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|