@momsfriendlydevco/cowboy 1.0.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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 1337 IP Pty Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,375 @@
1
+ @MomsfriendlyDevCo/Cowboy
2
+ =========================
3
+ A friendler wrapper around the Cloudflare [Wrangler SDK](https://github.com/cloudflare/workers-sdk)
4
+
5
+ Features:
6
+
7
+ * Automatic CORS handling
8
+ * Basic router support
9
+ * Express-like `req` + `res` object for routes
10
+ * Built in middleware + request validation via [Joi](https://joi.dev)
11
+ * Built-in debug support for testkits + Wrangler
12
+
13
+
14
+ Examples
15
+ ========
16
+
17
+ Simple request output
18
+ ---------------------
19
+ Example `src/worker.js` file providing a GET server which generates random company profiles:
20
+
21
+ ```javascript
22
+ import {faker} from '@faker-js/faker';
23
+ import cowboy from '@momsfriendlydevco/cowboy';
24
+
25
+ export default cowboy()
26
+ .use('cors') // Inject CORS functionality in every request
27
+ .get('/', ()=> ({
28
+ name: faker.company.name(),
29
+ motto: faker.company.catchPhrase(),
30
+ }))
31
+ ```
32
+
33
+
34
+
35
+ ReST server example
36
+ -------------------
37
+ Example `src/worker.js` file providing a GET / POST ReST-like server:
38
+
39
+ ```javascript
40
+ import cowboy from '@momsfriendlydevco/cowboy';
41
+
42
+ export default cowboy()
43
+ .use('cors')
44
+ .get('/widgets', ()=> // Fetch a list of widgets
45
+ widgetStore.fetchAll()
46
+ )
47
+ .post('/widgets', async (req, res) => { // Create a new widget
48
+ let newWidget = await widgetStore.create(req.body);
49
+ res.send({id: newWidget.id}); // Explicitly send response
50
+ })
51
+ .get('/widgets/:id', // Validate params + fetch an existing widget
52
+ ['validateParams', joi => ({ // Use the 'validateParams' middleware with options
53
+ id: joi.number().required().above(10000).below(99999),
54
+ })],
55
+ req => widgetStore.fetch(req.params.id),
56
+ )
57
+ .delete('/widgets/:id', // Try to delete a widget
58
+ (req, res) => { // Apply custom middleware
59
+ let isAllowed = await widgetStore.userIsValid(req.headers.auth);
60
+ if (!isAllowed) return res.sendStatus(403); // Stop bad actors
61
+ },
62
+ req => widgetStore.delete(req.params.id)
63
+ )
64
+ };
65
+ ```
66
+
67
+ Debugging
68
+ ---------
69
+ This module uses the [Debug NPM](https://github.com/visionmedia/debug#readme). To enable simply set the `DEBUG` environment variable to include `cowboy`.
70
+
71
+ Debugging workers in Testkits will automatically detect this token and enable debugging there. Use the `debug` export within Testkits to see output.
72
+
73
+
74
+
75
+ API
76
+ ===
77
+
78
+ cowboy()
79
+ --------
80
+ ```javascript
81
+ import cowboy from '@momsfriendlydevco/cowboy';
82
+ ```
83
+ Instanciate a `Cowboy` class instance and provide a simple router skeleton.
84
+
85
+
86
+ Cowboy
87
+ ------
88
+ ```javascript
89
+ import {Cowboy} from '@momsfriendlydevco/cowboy';
90
+ ```
91
+ The instance created by `cowboy()`.
92
+
93
+
94
+ Cowboy.delete(path) / .get() / .head() / .post() / .put() / .options()
95
+ ----------------------------------------------------------------------
96
+ Queue up a route with a given path.
97
+
98
+ Each component is made up of a path + any number of middleware handlers.
99
+
100
+ ```javascript
101
+ let router = new Cowboy()
102
+ .get('/my/path', middleware1, middleware2...)
103
+ ```
104
+
105
+ Notes:
106
+ * All middleware items are called in sequence - and are async waited-on)
107
+ * If any middleware functions fail the entire chain aborts with an error
108
+ * All middleware functions are called as `(CowboyRequest, CowboyResponse)`
109
+ * If any middleware functions call `res.end()` (or any of its automatic methods like `res.send()` / `res.sendStatus()`) the chain also aborts successfully
110
+ * If the last middleware function returns a non response object - i.e. the function didn't call `res.send()` its assumed to be a valid output and is automatically wrapped
111
+
112
+
113
+ Cowboy.use(middleware)
114
+ ----------------------
115
+ Queue up a universal middleware handler which will be used on *all* endpoints.
116
+ Middleware is called as per `Cowboy.get()` and its equivelents.
117
+
118
+
119
+ Cowboy.resolve(CowboyRequest)
120
+ -----------------------------
121
+ Find the matching route that would be used if given a prototype request.
122
+
123
+
124
+ Cowboy.fetch(CloudflareRequest, CloudflareEnv)
125
+ ----------------------------------------------
126
+ Execute the router when given various Cloudflare inputs.
127
+
128
+ This function will, in order:
129
+
130
+ 1. Enable debugging if required
131
+ 2. Create `(req:CowboyRequest, res:CowboyResponse)`
132
+ 3. Execute all middleware setup via `Cowboy.use()`
133
+ 4. Find a matching route - if no route is found, raise a 404 and quit
134
+ 5. Execute the matching route middleware, in sequence
135
+ 6. Return the final response - if it the function did not already explicitly do so
136
+
137
+
138
+ CowboyRequest
139
+ -------------
140
+ ```javascript
141
+ import CowboyRequest from '@momsfriendlydevco/cowboy/request';
142
+ ```
143
+ A wrapped version of the incoming `CloudflareRequest` object.
144
+
145
+ This object is identical to the original [CloudflareRequest](https://developers.cloudflare.com/workers/runtime-apis/request/#properties) object with the following additions:
146
+
147
+ | Property | Type | Description |
148
+ |------------|----------|----------------------------------------------------------|
149
+ | `path` | `String` | Extracted `url.pathname` portion of the incoming request |
150
+ | `hostname` | `String` | Extracted `url.hostname` portion of the incoming request |
151
+
152
+
153
+ CowboyResponse
154
+ --------------
155
+ ```javascript
156
+ import CowboyResponse from '@momsfriendlydevco/cowboy/request';
157
+ ```
158
+ An Express-like response object.
159
+ Calling any method which ends the session will cause the middleware chain to terminate and the response to be served back.
160
+
161
+ This object contains various Express-like utility functions:
162
+
163
+ | Method | Description |
164
+ |-------------------------------------|----------------------------------------------------------|
165
+ | `set(options)` | Set response output headers (using an object) |
166
+ | `set(header, value)` | Alternate method to set headers individually |
167
+ | `send(data, end=true)` | Set the output response and optionally end the session |
168
+ | `end(data?, end=true)` | Set the output response and optionally end the session |
169
+ | `sendStatus(code, data?, end=true)` | Send a HTTP response code and optionally end the session |
170
+ | `status(code)` | Set the HTTP response code |
171
+ | `toCloudflareResponse()` | Return the equivelent CloudflareResponse object |
172
+
173
+ All functions (except `toCloudflareResponse()`) are chainable and return the original `CowboyResponse` instance.
174
+
175
+
176
+ CowboyTestkit
177
+ -------------
178
+ ```javascript
179
+ import CowboyTestkit from '@momsfriendlydevco/cowboy/testkit';
180
+ ```
181
+ A series of utilities to help write testkits with Wrangler + Cowboy.
182
+
183
+
184
+ CowboyTestkit.cowboyMocha()
185
+ ---------------------------
186
+ Inject various Mocha before/after tooling.
187
+
188
+ ```javascript
189
+ import axios from 'axios';
190
+ import {cowboyMocha} from '@momsfriendlydevco/cowboy/testkit';
191
+ import {expect} from 'chai';
192
+
193
+ describe('My Wrangler Endpoint', ()=> {
194
+
195
+ // Inject Cowboy/mocha testkit handling
196
+ cowboyMocha({
197
+ axios,
198
+ });
199
+
200
+ let checkCors = headers => {
201
+ expect(headers).to.be.an.instanceOf(axios.AxiosHeaders);
202
+ expect(headers).to.have.property('access-control-allow-origin', '*');
203
+ expect(headers).to.have.property('access-control-allow-methods', 'GET, POST, OPTIONS');
204
+ expect(headers).to.have.property('access-control-allow-headers', '*');
205
+ expect(headers).to.have.property('content-type', 'application/json;charset=UTF-8');
206
+ };
207
+
208
+ it('should expose CORS headers', ()=>
209
+ axios('/', {
210
+ method: 'OPTIONS',
211
+ }).then(({data, headers}) => {
212
+ expect(data).to.be.equal('ok');
213
+ checkCors(headers);
214
+ })
215
+ );
216
+
217
+ it('should do something useful', ()=>
218
+ axios('/', {
219
+ method: 'get',
220
+ }).then(({data, headers}) => {
221
+ checkCors(headers);
222
+
223
+ // ... Your functionality checks ... //
224
+ })
225
+ );
226
+
227
+ });
228
+ ```
229
+
230
+
231
+ CowboyTestkit.start(options)
232
+ ----------------------------
233
+ Boot a wranger instance in the background and prepare for testing.
234
+ Returns a promise.
235
+
236
+ | Option | Type | Default | Description |
237
+ |----------------|------------|---------------|-----------------------------------------------------------|
238
+ | `axios` | `Axios` | | Axios instance to mutate with the base URL, if specified |
239
+ | `logOutput` | `Function` | | Function to wrap STDOUT output. Called as `(line:String)` |
240
+ | `logOutputErr` | `Function` | | Function to wrap STDERR output. Called as `(line:String)` |
241
+ | `host` | `String` | `'127.0.0.1'` | Host to run Wrangler on |
242
+ | `port` | `String` | `8787` | Host to run Wrangler on |
243
+ | `logLevel` | `String` | `'log'` | Log level to instruct Wrangler to run as |
244
+
245
+
246
+ CowboyTestkit.stop()
247
+ --------------------
248
+ Terminate any running Wrangler background processes.
249
+
250
+
251
+ Middleware
252
+ ==========
253
+ Cowboy ships with out-of-the-box middleware.
254
+ Middleware are simple functions which accept the paramters `(req:CowboyRequest, res:CowboyResponse)` and can modify the request, halt output with a call to `res` or perform other Async actions before continuing to the next middleware item.
255
+
256
+ To use middleware in your routes you can either declare it using `.use(middleware)` - which installs it globally or `.ROUTE(middleware...)` which installs it only for that route.
257
+
258
+ Middleware can be declared in the following ways:
259
+
260
+ ```javascript
261
+ import cowboy from '@momsfriendlydevco/cowboy';
262
+
263
+ // Shorthand with defaults - just specify the name
264
+ cowboy()
265
+ .get('/path',
266
+ 'cors',
267
+ (req, res) => /* ... */
268
+ )
269
+
270
+ // Name + options - specify an array with an optional options object
271
+ cowboy()
272
+ .get('/path',
273
+ ['cors', {
274
+ option1: value1,
275
+ /* ... */
276
+ }],
277
+ (req, res) => /* ... */
278
+ )
279
+
280
+
281
+ // Middleware function - include the import
282
+ import cors from '@momsfriendlydevco/cowboy/middleware/cors';
283
+ cowboy()
284
+ .get('/path',
285
+ cors({
286
+ option1: value1,
287
+ /* ... */
288
+ }),
289
+ (req, res) => /* ... */
290
+ )
291
+ ```
292
+
293
+
294
+ cors
295
+ ----
296
+ Inject simple CORS headers to allow websites to use the endpoint from the browser frontend.
297
+
298
+
299
+ validate
300
+ --------
301
+ Validate the incoming `req` object using [Joyful](https://github.com/MomsFriendlyDevCo/Joyful).
302
+ The only argument is the Joyful validator which is run against `req`.
303
+
304
+ ```javascript
305
+ import cowboy from '@momsfriendlydevco/cowboy';
306
+
307
+ // Shorthand with defaults - just specify the name
308
+ cowboy()
309
+ .get('/path',
310
+ ['validate', joi => {
311
+ body: {
312
+ widget: joi.string().required().valid('froody', 'doodad'),
313
+ size: joi.number().optional(),
314
+ },
315
+ })],
316
+ (req, res) => /* ... */
317
+ )
318
+ ```
319
+
320
+ validateBody
321
+ ------------
322
+ Shorthand validator which runs validation on the `req.body` parameter only.
323
+
324
+
325
+ ```javascript
326
+ import cowboy from '@momsfriendlydevco/cowboy';
327
+
328
+ // Shorthand with defaults - just specify the name
329
+ cowboy()
330
+ .get('/path',
331
+ ['validateBody', joi => {
332
+ widget: joi.string().required().valid('froody', 'doodad'),
333
+ size: joi.number().optional(),
334
+ })],
335
+ (req, res) => /* ... */
336
+ )
337
+ ```
338
+
339
+
340
+ validateParams
341
+ --------------
342
+ Shorthand validator which runs validation on the `req.params` parameter only.
343
+
344
+
345
+ ```javascript
346
+ import cowboy from '@momsfriendlydevco/cowboy';
347
+
348
+ // Shorthand with defaults - just specify the name
349
+ cowboy()
350
+ .get('/widgets/:id',
351
+ ['validateParams', joi => {
352
+ id: joi.string().requried(),
353
+ })],
354
+ (req, res) => /* ... */
355
+ )
356
+ ```
357
+
358
+
359
+ validateQuery
360
+ -------------
361
+ Shorthand validator which runs validation on the `req.query` parameter only.
362
+
363
+
364
+ ```javascript
365
+ import cowboy from '@momsfriendlydevco/cowboy';
366
+
367
+ // Shorthand with defaults - just specify the name
368
+ cowboy()
369
+ .get('/widgets/search',
370
+ ['validateQuery', joi => {
371
+ q: joi.string().requried(),
372
+ })],
373
+ (req, res) => /* ... */
374
+ )
375
+ ```
package/lib/cowboy.js ADDED
@@ -0,0 +1,178 @@
1
+ import debug from '#lib/debug';
2
+ import CowboyMiddleware from '#middleware';
3
+ import CowboyRequest from '#lib/request';
4
+ import CowboyResponse from '#lib/response';
5
+
6
+ export class Cowboy {
7
+ /**
8
+ * @name CowboyMiddleware
9
+ * @description An instance of a middleware item
10
+ * @type {Function|String|Array<String,Object>} Either a compiled function factory, pointer to a known CowboyMiddleware entity or Record(name, options) pair
11
+ */
12
+
13
+ /**
14
+ * @name CoyboyRoute
15
+ * @description A Cowboy Route entity
16
+ * @type {Object}
17
+ * @property {Array<String>} methods Methods to accept (each is an upper case HTTP method)
18
+ * @property {Array<String|RegExp>} paths Path matchers to accept
19
+ * @property {Array<CowboyMiddleware>} [middleware] Middleware / resolvers to use for the route, should it match
20
+ */
21
+
22
+ /**
23
+ * List of middleware which will be called on all matching routes
24
+ * @type Array<CowboyMiddleware>
25
+ */
26
+ earlyMiddleware = [];
27
+
28
+
29
+ /**
30
+ * List of routes which will be examined in order until a match occurs
31
+ * @type {Array<CowboyRoute>}
32
+ */
33
+ routes = [];
34
+
35
+
36
+ /**
37
+ * Queue up a middleware path
38
+ * All given middleware is called in sequence, if middleware
39
+ *
40
+ * @param {String|Array<String>} methods A method matcher or array of available methods
41
+ * @param {String|RegExp|Array<String|RegExp>} paths A prefix path to match
42
+ * @param {CowboyMiddleware} middleware... Middleware to call in sequence
43
+ *
44
+ * @returns {Cowboy} This chainable Cowboy router instance
45
+ */
46
+ route(methods, paths, ...middleware) {
47
+ this.routes.push({
48
+ methods: Array.isArray(methods) ? methods : [methods],
49
+ paths: Array.isArray(paths) ? paths : [paths],
50
+ middleware,
51
+ })
52
+ return this;
53
+ }
54
+
55
+
56
+ /**
57
+ * Prepend middleware which will be used for all routes
58
+ * @param {CowboyMiddleware} middleware Middleware to use
59
+ */
60
+ use(...middleware) {
61
+ this.earlyMiddleware.push(middleware);
62
+ return this;
63
+ }
64
+
65
+
66
+ /**
67
+ * Get the route to use when passed a prototype request
68
+ * @param {CowboyRequest} req The incoming request to match
69
+ * @returns {CowboyRoute} The matching route to use, if any
70
+ */
71
+ resolve(req) {
72
+ return this.routes.find(route =>
73
+ route.methods.includes(req.method) // Method matches
74
+ && route.paths.some(path => // Path matches
75
+ typeof path == 'string' ? req.path == path
76
+ : path instanceof RegExp ? path.test(req.path)
77
+ : (()=> { throw new Error('Path is not a String or RegExp') })()
78
+ )
79
+ );
80
+ }
81
+
82
+
83
+ /**
84
+ * Action an incoming route by resolving + walking down its middleware chain
85
+ * @param {CloudflareRequest} req The incoming request
86
+ * @param {Object} [env] Optional environment passed from Cloudflare
87
+ * @returns {Promise<CowboyResponse>} A promise which will eventually resolve when all middleware completes
88
+ */
89
+ async fetch(cfReq, env) {
90
+ if (env.COWBOY_DEBUG) {
91
+ debug.enabled = true;
92
+ debug('Cowboy Worker debugging is enabled');
93
+ }
94
+
95
+ // Create basic [req]uest / [res]ponse objects
96
+ let req = new CowboyRequest(cfReq, {router: this});
97
+ let res = new CowboyResponse();
98
+
99
+ // Exec all earlyMiddleware - every time
100
+ await this.execMiddleware({req, res, middleware: this.earlyMiddleware});
101
+
102
+ // Find matching route
103
+ let route = this.resolve(req);
104
+ if (!route) {
105
+ if (debug.enabled) {
106
+ debug(`No matching route for "${req.method} ${req.path}"`);
107
+ this.routes.forEach((r, i) =>
108
+ debug(
109
+ `Route #${i}`,
110
+ r.methods.length == 1 ? r.methods[0] : r.methods.join('|'),
111
+ r.paths.length == 1 ? r.paths[0] : r.paths.join('|'),
112
+ )
113
+ );
114
+ }
115
+ return res.sendStatus(404).toCloudflareResponse(); // No matching route
116
+ }
117
+
118
+ // Exec route middleware
119
+ let response = await this.execMiddleware({req, res, middleware: route.middleware});
120
+
121
+ if (!response) throw new Error('Middleware chain ended without returning a response!');
122
+ if (!response.toCloudflareResponse) throw new Error('Eventual middleware chain output should have a .toCloudflareResponse() method');
123
+ return response.toCloudflareResponse();
124
+ }
125
+
126
+
127
+ async execMiddleware({middleware, req, res}) {
128
+ let middlewareStack = [...middleware] // Shallow copy middleware stack to execute
129
+ .map(m => {
130
+ let mFunc =
131
+ typeof m == 'function' ? m // Already a function
132
+ : typeof m == 'string' ? CowboyMiddleware[m]() // Lookup from middleware with defaults
133
+ : Array.isArray(m) ? CowboyMiddleware[m[0]](m[1]) // Lookup from middleware with options
134
+ : (()=> { throw new Error(`Unknown middleware type "${typeof m}"`) })()
135
+
136
+ if (!mFunc) throw new Error('Cowboy Middleware must be a function, string or Record(name, options)');
137
+ return mFunc;
138
+ });
139
+
140
+ let response; // Response to eventually send
141
+ while (middlewareStack.length > 0) {
142
+ let middleware = middlewareStack.shift();
143
+ response = await middleware(req, res);
144
+ if (response?.hasSent) { // Stop middleware chain as some intermediate has signalled the chain should end
145
+ response = res;
146
+ break;
147
+ } else if (response && !(response instanceof CowboyResponse) && middlewareStack.length == 0) { // Last item in middleware chain returned something but it doesn't look like a regular response - wrap it
148
+ response = res.end(response);
149
+ }
150
+ }
151
+ return response;
152
+ }
153
+
154
+
155
+ // Alias functions to route
156
+ delete(path, ...middleware) { return this.route('DELETE', path, ...middleware) }
157
+ get(path, ...middleware) { return this.route('GET', path, ...middleware) }
158
+ head(path, ...middleware) { return this.route('HEAD', path, ...middleware) }
159
+ post(path, ...middleware) { return this.route('POST', path, ...middleware) }
160
+ put(path, ...middleware) { return this.route('PUT', path, ...middleware) }
161
+ options(path, ...middleware) { return this.route('OPTIONS', path, ...middleware) }
162
+ }
163
+
164
+
165
+ /**
166
+ * Wrap an incoming Wrangler request
167
+ * @returns {Object} A Wrangler compatible object
168
+ */
169
+ export default function cowboy(options) {
170
+ let cowboyInstance = new Cowboy(options);
171
+
172
+ // Utterly ridiculous fix to subclass 'fetch' as a POJO function as Wrangler seems to only check hasOwnProperty for the fetch method
173
+ Object.assign(cowboyInstance, {
174
+ fetch: cowboyInstance.fetch.bind(cowboyInstance),
175
+ });
176
+
177
+ return cowboyInstance;
178
+ }
package/lib/debug.js ADDED
@@ -0,0 +1,8 @@
1
+ export default function CowboyDebug(...msg) {
2
+ if (!CowboyDebug.enabled) return;
3
+ console.log('COWBOY-DEBUG', ...msg.map(m =>
4
+ typeof m == 'string' ? m
5
+ : JSON.stringify(m)
6
+ ))
7
+ }
8
+ CowboyDebug.enabled = false;
package/lib/request.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tiny wrapper around Wrangler to wrap its default Request object in an Express-like structure
3
+ * @extends CloudflareRequest
4
+ */
5
+ export default class CowboyRequest {
6
+ /**
7
+ * Extracted request path with leading slash
8
+ * @type {String}
9
+ */
10
+ path;
11
+
12
+
13
+ /**
14
+ * Extracted hostname being addressed
15
+ */
16
+ hostname;
17
+
18
+
19
+ constructor(cfReq, props) {
20
+ // Copy all cfReq keys locally as a shallow copy
21
+ Object.assign(
22
+ this,
23
+ Object.fromEntries(
24
+ Object.keys(Request.prototype).map(key =>
25
+ [
26
+ key,
27
+ cfReq[key],
28
+ ]
29
+ )
30
+ ),
31
+ props,
32
+ );
33
+
34
+ // Break appart the incoming URL
35
+ let url = new URL(cfReq.url);
36
+ this.path = url.pathname;
37
+ this.hostname = url.hostname;
38
+ }
39
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Generic all-in-one response wrapper to mangle responses without having to memorize all the weird syntax that Wrangler / Cloudflare workers need
3
+ */
4
+ export default class CowboyResponse {
5
+ body = '';
6
+ code = null;
7
+ headers = {};
8
+ hasSent = false;
9
+ CloudflareResponse = Response;
10
+
11
+ /**
12
+ * Assign various output headers
13
+ * @param {Object|String} options Either an header object to be merged or the header to set
14
+ * @param {*} [value] If `options` is a string, the value of the header
15
+ * @returns {CowboyResponse} This chainable instance
16
+ */
17
+ set(options, value) {
18
+ if (typeof options == 'string') {
19
+ this.headers[options] = value;
20
+ } else {
21
+ Object.assign(this.headers, options);
22
+ }
23
+
24
+ return this;
25
+ }
26
+
27
+
28
+ /**
29
+ * Send data and (optionally) mark the response as complete
30
+ * @param {*} data The data to transmit
31
+ * @param {Boolean} [end=true] Whether to also end the transmision
32
+ * @returns {CowboyResponse} This chainable instance
33
+ */
34
+ send(data, end = true) {
35
+ if (this.code === null) this.code = 200; // Assume OK if not told otherwise
36
+
37
+ if (typeof data == 'string') {
38
+ this.body = data;
39
+ } else {
40
+ this.body = JSON.stringify(data);
41
+ }
42
+
43
+ // Mark transmition as ended
44
+ if (end) this.hasSent = true;
45
+
46
+ return this;
47
+ }
48
+
49
+
50
+ /**
51
+ * Mark the transmission as complete
52
+ * @param {*} [data] Optional data to send before ending
53
+ * @returns {CowboyResponse} This chainable instance
54
+ */
55
+ end(data) {
56
+ if (data) this.send(data);
57
+ this.hasSent = true;
58
+ return this;
59
+ }
60
+
61
+
62
+ /**
63
+ * Set the status code we are responding with
64
+ * @param {Number} code The HTTP response code to respond with
65
+ * @returns {CowboyResponse} This chainable instance
66
+ */
67
+ status(code) {
68
+ this.code = code;
69
+ if (!this.body) this.body = 'ok'; // Set body payload if we don't already have one
70
+ return this;
71
+ }
72
+
73
+
74
+ /**
75
+ * Set the response status code and (optionally) end the transmission
76
+ * @param {Number} code The HTTP response code to respond with
77
+ * @param {*} [data] Optional data to send before ending
78
+ * @param {Boolean} [end=true] Whether to also end the transmision
79
+ * @returns {CowboyResponse} This chainable instance
80
+ */
81
+ sendStatus(code, data, end = true) {
82
+ if (data) throw new Error('Data is not allowed with CowboyResponse.sendStatus(code) - use CowBoyresponse.status(CODE).send(DATA) instead');
83
+ this.status(code);
84
+ if (end) this.end();
85
+ return this;
86
+ }
87
+
88
+
89
+ /**
90
+ * Convert the current CoyboyResponse into a CloudflareResponse object
91
+ * @returns {CloudflareResponse} The cloudflare output object
92
+ */
93
+ toCloudflareResponse() {
94
+ let cfOptions = {
95
+ status: this.code,
96
+ headers: this.headers,
97
+ };
98
+ console.log('Build response', JSON.stringify(cfOptions));
99
+ return new this.CloudflareResponse(this.body, cfOptions);
100
+ }
101
+ }
package/lib/testkit.js ADDED
@@ -0,0 +1,139 @@
1
+ import Debug from 'debug';
2
+ import fs from 'node:fs/promises';
3
+ import {spawn} from 'node:child_process';
4
+ import toml from 'toml';
5
+
6
+ const debug = Debug('cowboy');
7
+
8
+ /**
9
+ * The currently active worker (if any)
10
+ * @type {ChildProcess}
11
+ */
12
+ export let worker;
13
+
14
+
15
+ /**
16
+ * Boot a wranger instance in the background
17
+ *
18
+ * @param {Object} [options] Additional options to mutate behaviour
19
+ * @param {Axios} [options.axios] Axios instance to mutate with the base URL, if specified
20
+ * @param {Function} [options.logOutput] Function to wrap STDOUT output. Called as `(line:String)`
21
+ * @param {Function} [options.logOutputErr] Function to wrap STDERR output. Called as `(line:String)`
22
+ * @param {String} [options.host='127.0.0.1'] Host to run Wrangler on
23
+ * @param {String} [options.port=8787] Host to run Wrangler on
24
+ * @param {String} [options.logLevel='log'] Log level to instruct Wrangler to run as
25
+ *
26
+ * @returns {Promise} A promise which resolves when the operation has completed
27
+ */
28
+ export function start(options) {
29
+ let settings = {
30
+ axios: null,
31
+ logOutput: output => console.log('WRANGLER>', output),
32
+ logOutputErr: output => console.log('WRANGLER!', output),
33
+ host: '127.0.0.1',
34
+ port: 8787,
35
+ logLevel: 'log',
36
+ ...options,
37
+ };
38
+
39
+ debug('Start cowboy testkit');
40
+ let wranglerConfig; // Eventual wrangler config
41
+
42
+ return Promise.resolve()
43
+ // Read in project `wrangler.toml` {{{
44
+ .then(()=> fs.readFile('wrangler.toml', 'utf8'))
45
+ .then(contents => toml.parse(contents))
46
+ .then(config => {
47
+ debug('Read config', config);
48
+ if (!Object.hasOwn(config, 'send_metrics')) throw new Error('Please append `send_metrics = false` to wrangler.toml to Warngler asking questions during boot');
49
+ wranglerConfig = config;
50
+ })
51
+ // }}}
52
+ // Launch worker {{{
53
+ .then(()=> {
54
+ debug('Running Wrangler against script', wranglerConfig.main);
55
+
56
+ let isRunning = false;
57
+ return new Promise((resolve, reject) => {
58
+ worker = spawn('node', [
59
+ './node_modules/.bin/wrangler',
60
+ 'dev',
61
+ `--host=${settings.host}`,
62
+ `--port=${settings.port}`,
63
+ `--log-level=${settings.logLevel}`,
64
+ ...(debug.enabled ? [
65
+ '--var=COWBOY_DEBUG:1'
66
+ ]: []),
67
+ ]);
68
+
69
+ worker.stdout.on('data', data => {
70
+ let output = data.toString().replace(/\r?\n$/, '');
71
+
72
+ if (!isRunning && /Ready on https?:\/\//.test(output)) {
73
+ isRunning = true;
74
+ resolve();
75
+ }
76
+
77
+ settings.logOutput(output);
78
+ });
79
+
80
+ worker.stderr.on('data', data => {
81
+ settings.logOutputErr(data.toString().replace(/\r?\n$/, ''))
82
+ });
83
+
84
+ worker.on('error', reject);
85
+
86
+ worker.on('close', code => {
87
+ debug('Wrangler exited with code', code);
88
+ worker = null;
89
+ })
90
+ });
91
+ })
92
+ // }}}
93
+ // .then(()=> new Promise(resolve => setTimeout(resolve, 10 * 1000)))
94
+ // Mutate axios if provided {{{
95
+ .then(()=> {
96
+ if (settings.axios) {
97
+ let baseURL = `http://${settings.host}:${settings.port}`;
98
+ debug('Setting axios BaseURL', baseURL);
99
+ settings.axios.defaults.baseURL = baseURL;
100
+ }
101
+ })
102
+ // }}}
103
+ }
104
+
105
+ /**
106
+ * Stop background wrangler instances
107
+ * @returns {Promise} A promise which resolves when the operation has completed
108
+ */
109
+ export function stop() {
110
+ if (!worker) return; // Worker not active anyway
111
+ debug('Stop cowboy testkit');
112
+
113
+ debug(`Stopping active Wrangler worker PID #${worker.pid}`);
114
+ worker.kill('SIGTERM');
115
+ }
116
+
117
+ /**
118
+ * Inject various Mocha before/after tooling
119
+ * @param {Object} [options] Additional options to pass to `start()`
120
+ */
121
+ export function cowboyMocha(options) {
122
+
123
+ before('start cowboy/testkit', function() {
124
+ this.timeout(30 * 1000);
125
+ return start(options);
126
+ });
127
+
128
+ after('stop cowboy/testkit', function() {
129
+ this.timeout(5 * 1000);
130
+ return stop();
131
+ });
132
+
133
+ }
134
+
135
+ export default {
136
+ cowboyMocha,
137
+ stop,
138
+ start,
139
+ };
@@ -0,0 +1,18 @@
1
+ export default function CowboyMiddlewareCORS(headers) {
2
+ let injectHeaders = headers || {
3
+ 'Access-Control-Allow-Origin': '*',
4
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
5
+ 'Access-Control-Allow-Headers': '*',
6
+ 'Content-Type': 'application/json;charset=UTF-8',
7
+ };
8
+
9
+ return (req, res) => {
10
+ // Always inject CORS headers
11
+ res.set(injectHeaders);
12
+
13
+ // Handle hits to OPTIONS '/' endpoint
14
+ req.router.options('/', (req, res) => {
15
+ return res.sendStatus(200);
16
+ });
17
+ }
18
+ }
@@ -0,0 +1,13 @@
1
+ import cors from '#middleware/cors';
2
+ import validate from '#middleware/validate';
3
+ import validateBody from '#middleware/validateBody';
4
+ import validateParams from '#middleware/validateParams';
5
+ import validateQuery from '#middleware/validateQuery';
6
+
7
+ export default {
8
+ cors,
9
+ validate,
10
+ validateBody,
11
+ validateParams,
12
+ validateQuery,
13
+ }
@@ -0,0 +1,13 @@
1
+ import joyful from '@momsfriendlydevco/joyful';
2
+
3
+ export default function CowboyMiddlewareValidate(validator) {
4
+ return (req, res) => {
5
+ let joyfulResult = joyful(req, validator, {throw: false});
6
+
7
+ if (joyfulResult !== true) { // Failed body validation?
8
+ return res
9
+ .status(400)
10
+ .send(joyfulResult)
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,7 @@
1
+ import CowboyMiddlewareValidate from '#middleware/validate';
2
+
3
+ export default function CowboyMiddlewareValidateBody(validator) {
4
+ return CowboyMiddlewareValidate({
5
+ body: validator,
6
+ })
7
+ }
@@ -0,0 +1,7 @@
1
+ import CowboyMiddlewareValidate from '#middleware/validate';
2
+
3
+ export default function CowboyMiddlewareValidateParams(validator) {
4
+ return CowboyMiddlewareValidate({
5
+ params: validator,
6
+ })
7
+ }
@@ -0,0 +1,7 @@
1
+ import CowboyMiddlewareValidate from '#middleware/validate';
2
+
3
+ export default function CowboyMiddlewareValidateQuery(validator) {
4
+ return CowboyMiddlewareValidate({
5
+ query: validator,
6
+ })
7
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@momsfriendlydevco/cowboy",
3
+ "version": "1.0.0",
4
+ "description": "Wrapper around Cloudflare Wrangler to provide a more Express-like experience",
5
+ "scripts": {
6
+ "lint": "eslint ."
7
+ },
8
+ "keywords": [
9
+ "wrangler"
10
+ ],
11
+ "type": "module",
12
+ "imports": {
13
+ "#lib/*": "./lib/*.js",
14
+ "#middleware": "./middleware/index.js",
15
+ "#middleware/*": "./middleware/*.js"
16
+ },
17
+ "exports": {
18
+ ".": "./lib/cowboy.js",
19
+ "./*": "./lib/*.js",
20
+ "middleware": "./middleware/index.js",
21
+ "middleware/*": "./middleware/*.js"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/MomsFriendlyDevCo/Cowboy.git"
26
+ },
27
+ "author": "Matt Carter <matt@mfdc.biz>",
28
+ "license": "MIT",
29
+ "bugs": {
30
+ "url": "https://github.com/MomsFriendlyDevCo/Cowboy/issues"
31
+ },
32
+ "homepage": "https://github.com/MomsFriendlyDevCo/Cowboy#readme",
33
+ "engineStrict": false,
34
+ "engines": {
35
+ "node": ">=20.x"
36
+ },
37
+ "dependencies": {
38
+ "@momsfriendlydevco/joyful": "^1.0.0",
39
+ "toml": "^3.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@momsfriendlydevco/eslint-config": "^1.0.7",
43
+ "eslint": "^8.50.0"
44
+ },
45
+ "eslintConfig": {
46
+ "extends": "@momsfriendlydevco",
47
+ "env": {
48
+ "es6": true,
49
+ "node": true
50
+ },
51
+ "parserOptions": {
52
+ "ecmaVersion": 13,
53
+ "sourceType": "module"
54
+ }
55
+ }
56
+ }