@momsfriendlydevco/cowboy 1.6.1 → 1.7.1

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
@@ -108,8 +108,8 @@ import {Cowboy} from '@momsfriendlydevco/cowboy';
108
108
  The instance created by `cowboy()`.
109
109
 
110
110
 
111
- Cowboy.delete(path) / .get() / .head() / .post() / .put() / .options()
112
- ----------------------------------------------------------------------
111
+ Cowboy.delete(path) / .all() / .get() / .head() / .post() / .put() / .options()
112
+ -------------------------------------------------------------------------------
113
113
  Queue up a route with a given path.
114
114
 
115
115
  Each component is made up of a path + any number of middleware handlers.
package/lib/cowboy.js CHANGED
@@ -178,10 +178,12 @@ export class Cowboy {
178
178
  router: this,
179
179
  pathTidy: this.settings.pathTidy,
180
180
  });
181
+ req.cowboy = this;
181
182
 
182
183
  await req.parseBody();
183
184
 
184
185
  let res = new CowboyResponse();
186
+ res.cowboy = this;
185
187
  debug('Incoming request:', req.toString());
186
188
 
187
189
  // Exec all earlyMiddleware - every time
@@ -203,7 +205,7 @@ export class Cowboy {
203
205
  )
204
206
  );
205
207
  }
206
- return await res.sendStatus(404).toCloudflareResponse(); // No matching route
208
+ return await res.sendStatus(404).toCloudflareResponse(req); // No matching route
207
209
  }
208
210
 
209
211
  // Populate params
@@ -218,7 +220,7 @@ export class Cowboy {
218
220
 
219
221
  if (!response) throw new Error('Middleware chain ended without returning a response!');
220
222
  if (!response.toCloudflareResponse) throw new Error('Eventual middleware chain output should have a .toCloudflareResponse() method');
221
- return await response.toCloudflareResponse();
223
+ return await response.toCloudflareResponse(req);
222
224
  }
223
225
 
224
226
 
@@ -318,9 +320,11 @@ export class Cowboy {
318
320
 
319
321
 
320
322
  // Alias functions to route
323
+ all(path, ...middleware) { return this.route(['DELETE', 'GET', 'PATCH', 'POST', 'PUT'], path, ...middleware) }
321
324
  delete(path, ...middleware) { return this.route('DELETE', path, ...middleware) }
322
325
  get(path, ...middleware) { return this.route('GET', path, ...middleware) }
323
326
  head(path, ...middleware) { return this.route('HEAD', path, ...middleware) }
327
+ patch(path, ...middleware) { return this.route('PATCH', path, ...middleware) }
324
328
  post(path, ...middleware) { return this.route('POST', path, ...middleware) }
325
329
  put(path, ...middleware) { return this.route('PUT', path, ...middleware) }
326
330
  options(path, ...middleware) { return this.route('OPTIONS', path, ...middleware) }
package/lib/request.js CHANGED
@@ -5,6 +5,22 @@ import debug from '#lib/debug';
5
5
  * @augments {CloudflareRequest}
6
6
  */
7
7
  export default class CowboyRequest {
8
+
9
+ /**
10
+ * Handle to the parent `cowboy` instance
11
+ *
12
+ * @type {Cowboy}
13
+ */
14
+ cowboy;
15
+
16
+
17
+ /**
18
+ * The original Cloudflare Request object
19
+ * @type {CloudflareRequest}
20
+ */
21
+ cfReq;
22
+
23
+
8
24
  /**
9
25
  * Extracted request path with leading slash
10
26
  * @type {String}
@@ -42,6 +58,8 @@ export default class CowboyRequest {
42
58
 
43
59
 
44
60
  constructor(cfReq, props) {
61
+ this.cfReq = cfReq;
62
+
45
63
  // Copy all cfReq keys locally as a shallow copy
46
64
  Object.assign(
47
65
  this,
package/lib/response.js CHANGED
@@ -4,6 +4,7 @@ import debug from '#lib/debug';
4
4
  * Generic all-in-one response wrapper to mangle responses without having to memorize all the weird syntax that Wrangler / Cloudflare workers need
5
5
  */
6
6
  export default class CowboyResponse {
7
+ cowboy;
7
8
  body = '';
8
9
  code = null;
9
10
  headers = {};
@@ -138,11 +139,13 @@ export default class CowboyResponse {
138
139
 
139
140
  /**
140
141
  * Convert the current CoyboyResponse into a CloudflareResponse object
142
+ *
143
+ * @param {CowboyRequest} [req] Optional CowboyRequest object to use when computing the response
141
144
  * @returns {CloudflareResponse} The cloudflare output object
142
145
  */
143
- async toCloudflareResponse() {
146
+ async toCloudflareResponse(req) {
144
147
  // Await all beforeServeCallbacks
145
- await Array.fromAsync(this.beforeServeCallbacks, cb => cb.call(this, this));
148
+ await Array.fromAsync(this.beforeServeCallbacks, cb => cb.call(this, req, this));
146
149
 
147
150
  debug('CF-Response', JSON.stringify({
148
151
  status: this.code,
@@ -0,0 +1,166 @@
1
+ /**
2
+ * APITally LogPush output support
3
+ * This middleware outputs a Base64 + Gziped trace for digest by the remote APITally.io service
4
+ *
5
+ * @param {Object} [options] Additional options to mutate behaviour
6
+ * @param {Boolean|Function} [options.enabled] Whether to perform an output action, defaults to being enabled only if we are likely running within a Cloudflare Co-location
7
+ * @param {String} [options.pathPrefix=''] Prefix to prepend to all endpoint paths
8
+ * @param {String} [options.client='js-serverless:hono'] The client string to use when reporting to APITally
9
+ * @param {String} [options.clientVersion] The client version string to use when reporting to APITally
10
+ * @param {Set<String>} [options.logMethods] Methods whitelist to use when computing the endpoints to APITally
11
+ *
12
+ * @returns {CowboyMiddleware}
13
+ */
14
+ export default function CowboyMiddlewareApiTally(options) {
15
+ let settings = {
16
+ enabled(req, res) {
17
+ return !! req.cfReq.cf?.colo; // Only run if we have a Cloudflare data center allocated - otherwise assume local execution
18
+ },
19
+ pathPrefix: '',
20
+ client: 'js-serverless:hono',
21
+ clientVersion: '1.0.0',
22
+ logMethods: new Set([
23
+ 'DELETE',
24
+ 'GET',
25
+ 'PATCH',
26
+ 'POST',
27
+ 'PUT',
28
+ ]),
29
+ ...options,
30
+ };
31
+
32
+ let isFirstRequest = true;
33
+
34
+ return (req, res) => {
35
+ req.startTime = Date.now();
36
+
37
+ // Queue up callback function to call after handling the request
38
+ res.beforeServe(async (req, res) => {
39
+ if ( // Skip adding APITally output if we're not enabled
40
+ !settings.enabled
41
+ || (
42
+ typeof settings.enabled == 'function'
43
+ && !(await settings.enabled(req, res))
44
+ )
45
+ ) return;
46
+
47
+ /**
48
+ * Actual output data structure to encode + output
49
+ * This is copied from https://github.com/apitally/apitally-js-serverless/blob/main/src/hono/middleware.ts
50
+ * @type {Object}
51
+ */
52
+ let outputData = {
53
+ instanceUuid: crypto.randomUUID(), // NOTE: Docs say required string but composer returns undefined outside of isFirstRequest
54
+ requestUuid: crypto.randomUUID(),
55
+ consumer: undefined, // FIXME: No idea what this is but its optional anyway
56
+ startup: isFirstRequest ? {
57
+ paths: res.cowboy.routes
58
+ .flatMap(route =>
59
+ route.methods
60
+ .filter(method => settings.logMethods.has(method))
61
+ .map(method =>
62
+ route.matcher.pathStrings
63
+ .filter(path => path.startsWith('/')) // Exclude complex RegEx matches
64
+ .map(path => ({
65
+ method,
66
+ path: settings.pathPrefix + path,
67
+ }))
68
+ )
69
+ ),
70
+ client: settings.client,
71
+ versions: {
72
+ '@apitally/serverless': settings.clientVersion,
73
+ },
74
+ } : undefined,
75
+ request: {
76
+ path: req.cfReq.routePath,
77
+ headers: Object.entries(req.headers),
78
+ size: req.cfReq.headers.has('content-length') ? Number.parseInt(req.cfReq.headers.get('content-length')) : undefined,
79
+ consumer: undefined, // FIXME: Where does this come from type is optional string
80
+ body: bytesToBase64(pojoToUint8Array(req.body)),
81
+ },
82
+ response: {
83
+ responseTime: Math.floor((Date.now() - req.startTime) / 1000),
84
+ headers: Object.entries(res.headers),
85
+ size:
86
+ res.headers['Content-Type'] == 'application/json' ? JSON.stringify(res.body).length
87
+ : res.headers['Content-Type'].startsWith('text/') ? res.body.length
88
+ : undefined,
89
+ body:
90
+ res.headers['Content-Type'] == 'application/json' ? bytesToBase64(pojoToUint8Array(res.body))
91
+ : res.headers['Content-Type'].startsWith('text/') ? new TextEncoder().encode(res.body)
92
+ : undefined,
93
+ },
94
+ validationErrors: undefined, // FIXME: Populate somehow, type is ValidationError[]
95
+ exception: undefined, // FIXME: Populate somehow when erroring out, type struct below
96
+ /*
97
+ res.code === 500 && res.error
98
+ ? {
99
+ type: res.error.name, // FIXME: string
100
+ msg: truncateExceptionMessage(res.error.message), // FIXME: string
101
+ stackTrace: truncateExceptionStackTrace(res.error.stack ?? ""), // FIXME: string
102
+ }
103
+ : undefined,
104
+ */
105
+ };
106
+
107
+ // console.log('APITally data', JSON.stringify(outputData, null, '\t'));
108
+
109
+ console.log('apitally:' + await gzipBase64(JSON.stringify(outputData)));
110
+ if (isFirstRequest) isFirstRequest = false; // Disable need for more endpoint reporting after the first report
111
+ });
112
+ };
113
+ }
114
+
115
+
116
+ // Utilify functions taken from source {{{
117
+
118
+ /**
119
+ * Convert a standard JS POJO into a Uint8Array type
120
+ *
121
+ * @param {Object|Array} obj Input object to work with
122
+ * @returns {Uint8Array} Output Uint8Array type
123
+ */
124
+ function pojoToUint8Array(obj) {
125
+ return new TextEncoder().encode(
126
+ JSON.stringify(obj)
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Convert an incoming Uint8Array into Base64 encoding
132
+ *
133
+ * @see https://github.com/apitally/apitally-js-serverless/blob/main/src/common/bytes.ts
134
+ *
135
+ * @param {Uint8Array} bytes The incoming byte stream to convert
136
+ * @returns {String} Base64 Encoded content
137
+ */
138
+ function bytesToBase64(bytes) {
139
+ /* eslint-disable unicorn/numeric-separators-style, unicorn/prefer-code-point */
140
+ const chunks = [];
141
+ const chunkSize = 0x1000; // 4096 bytes
142
+ for (let i = 0; i < bytes.length; i += chunkSize) {
143
+ chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
144
+ }
145
+ return btoa(chunks.join(""));
146
+ }
147
+
148
+
149
+ /**
150
+ * Encode a given input string via Gzip
151
+ *
152
+ * @param {String} json The input string to encode
153
+ * @returns {Uint8Array} The encoded input
154
+ */
155
+ async function gzipBase64(json) {
156
+ const encoder = new TextEncoder();
157
+ const gzipStream = new CompressionStream("gzip");
158
+ const writer = gzipStream.writable.getWriter();
159
+ writer.write(encoder.encode(json));
160
+ writer.close();
161
+
162
+ const compressed = await new Response(gzipStream.readable).arrayBuffer();
163
+ return bytesToBase64(new Uint8Array(compressed));
164
+ }
165
+
166
+ // }}}
@@ -1,4 +1,10 @@
1
- // Utility: sortKeys(o) - Re-sort a JSON object so that keys are in a predictable order {{{
1
+ // Utility: sortKeys(o) - Re-compose a JSON object so that keys are in a predictable order {{{
2
+ /**
3
+ * Re-compose a JSON object so that keys are in a predictable order
4
+ *
5
+ * @param {Object} o Incoming object/value to resort
6
+ * @returns {Object} The incoming object, shallow copied into another with sorted keys
7
+ */
2
8
  function sortKeys(o) {
3
9
  if (typeof o !== 'object' || o === null) return o;
4
10
  if (Array.isArray(o)) return o.map(sortKeys);
@@ -25,7 +31,7 @@ function sortKeys(o) {
25
31
  *
26
32
  * @returns {CowboyMiddleware} A CowboyMiddleware compatible function - this can be used on individual requests or globally
27
33
  */
28
- export default function CowboyEtagCaching(options) {
34
+ export default function CowboyMiddlewareEtagCaching(options) {
29
35
  let settings = {
30
36
  enabled(req, settings) { // eslint-disable-line no-unused-vars
31
37
  return (
@@ -1,3 +1,4 @@
1
+ import apiTally from '#middleware/apiTally';
1
2
  import cors from '#middleware/cors';
2
3
  import devOnly from '#middleware/devOnly';
3
4
  import etagCaching from '#middleware/etagCaching';
@@ -9,6 +10,7 @@ import validateParams from '#middleware/validateParams';
9
10
  import validateQuery from '#middleware/validateQuery';
10
11
 
11
12
  export default {
13
+ apiTally,
12
14
  cors,
13
15
  devOnly,
14
16
  etagCaching,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momsfriendlydevco/cowboy",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Wrapper around Cloudflare Wrangler to provide a more Express-like experience",
5
5
  "scripts": {
6
6
  "lint": "eslint"