@inteli.city/node-red-contrib-http-plus 1.0.0 → 1.0.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.
Files changed (3) hide show
  1. package/httpin+.html +66 -12
  2. package/httpin+.js +78 -1
  3. package/package.json +1 -1
package/httpin+.html CHANGED
@@ -80,6 +80,17 @@
80
80
  <p><b>Cognito JWT:</b> validates a Bearer token against a JWKS endpoint. Enable <b>"Expose user to flow"</b> in the config node to populate <code>msg.user</code> with mapped JWT claims. When disabled, no JWT data enters the flow.</p>
81
81
  <p>After successful authentication, <code>Authorization</code> headers and <code>?token=</code> query parameters are always removed before the message is dispatched.</p>
82
82
 
83
+ <h3>Backend Cache</h3>
84
+ <p>Enable <b>Enable backend cache</b> to cache GET responses on the server. Repeated identical requests skip the flow entirely and return the cached response.</p>
85
+ <ul>
86
+ <li>Only active for GET requests with no authentication.</li>
87
+ <li>Cache key is derived from the URL path and query parameters.</li>
88
+ <li>Responses are stored in <code>&lt;userDir&gt;/cache/http+/</code>.</li>
89
+ <li>Expired entries are ignored lazily — no active cleanup is needed.</li>
90
+ <li>The response header <code>X-Cache: HIT</code> or <code>MISS</code> indicates cache status.</li>
91
+ </ul>
92
+ <p>Not suitable for dynamic or user-specific responses.</p>
93
+
83
94
  <h3>Details</h3>
84
95
  <p>The URL field supports Express route patterns, including named parameters such as <code>/users/:id</code>.</p>
85
96
  <p>For more examples see the package <a href="https://github.com/inteli-city/node-red-contrib-http-plus">README</a>.</p>
@@ -104,14 +115,15 @@
104
115
 
105
116
  <h3>Details</h3>
106
117
  <p>This node is the standard response pair for <b>http.in+</b>.</p>
107
- <p>It is fully compatible with the built-in Node-RED <i>http response</i> node, with one key addition: support for streaming responses.</p>
118
+ <p>It is fully compatible with the built-in Node-RED <i>http response</i> node, with two additions: browser cache control and streaming support.</p>
108
119
  <p>The <code>msg.res</code> object from the originating <b>http.in+</b> must reach this node unchanged.</p>
109
120
 
110
- <h3>Difference from the standard node</h3>
111
- <p>The only behavioral difference from the built-in <i>http response</i> node is support for streaming.</p>
121
+ <h3>Browser Cache</h3>
122
+ <p>Enable <b>Enable browser cache</b> to automatically set <code>Cache-Control: public, max-age=&lt;seconds&gt;</code> on every response.</p>
112
123
  <ul>
113
- <li>If <code>msg.stream</code> is set, the stream is piped directly to the HTTP response.</li>
114
- <li>If not, the node behaves exactly like the standard <i>http response</i> node.</li>
124
+ <li>Safe for static assets (images, scripts, stylesheets).</li>
125
+ <li>Avoid for dynamic or user-specific responses.</li>
126
+ <li>If <code>msg.headers['cache-control']</code> is already set in the flow, it takes precedence and the node-level setting is ignored.</li>
115
127
  </ul>
116
128
 
117
129
  <h3>Streaming</h3>
@@ -210,25 +222,51 @@
210
222
  <b>Zod docs</b><br>
211
223
  <a href="https://zod.dev/api" target="_blank">https://zod.dev/api</a>
212
224
  </div>
225
+ <div class="form-row">
226
+ <input type="checkbox" id="node-input-enableCache" style="display:inline-block; width:auto; vertical-align:top;">
227
+ <label for="node-input-enableCache" style="width:auto;"> Enable backend cache</label>
228
+ </div>
229
+ <div class="form-row httpin-cache-duration-row">
230
+ <label for="node-input-cacheDuration"><i class="fa fa-clock-o"></i> Cache duration (s)</label>
231
+ <input type="number" id="node-input-cacheDuration" style="width:100px;" min="1" placeholder="300">
232
+ </div>
233
+ <div class="httpin-cache-duration-row" style="font-size:12px; color:#666; margin: -4px 0 8px 110px; line-height:1.5;">
234
+ Caches GET responses server-side. Repeated identical requests skip the flow entirely.<br>
235
+ Only active for unauthenticated GET requests.<br>
236
+ Not suitable for dynamic or user-specific responses.
237
+ </div>
213
238
  <div id="node-input-tip" class="form-tips"><span data-i18n="httpin.tip.in"></span><code><span id="node-input-path"></span></code>.</div>
214
239
  </script>
215
240
 
216
241
  <script type="text/html" data-template-name="http.out+">
217
242
  <div class="form-row">
218
- <label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="common.label.name"></span></label>
219
- <input type="text" id="node-input-name" data-i18n="[placeholder]common.label.name">
243
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
244
+ <input type="text" id="node-input-name" placeholder="Name">
220
245
  </div>
221
246
  <div class="form-row">
222
- <label for="node-input-statusCode"><i class="fa fa-long-arrow-left"></i> <span data-i18n="httpin.label.status"></span></label>
247
+ <label for="node-input-statusCode"><i class="fa fa-long-arrow-left"></i> Status code</label>
223
248
  <input type="text" id="node-input-statusCode" placeholder="msg.statusCode">
224
249
  </div>
250
+ <div class="form-row">
251
+ <input type="checkbox" id="node-input-enableCache" style="display:inline-block; width:auto; vertical-align:top;">
252
+ <label for="node-input-enableCache" style="width:auto;"> Enable browser cache</label>
253
+ </div>
254
+ <div class="form-row http-out-cache-duration-row">
255
+ <label for="node-input-cacheDuration"><i class="fa fa-clock-o"></i> Cache duration (s)</label>
256
+ <input type="number" id="node-input-cacheDuration" style="width:100px;" min="1" placeholder="3600">
257
+ </div>
258
+ <div class="http-out-cache-duration-row" style="font-size:12px; color:#666; margin: -4px 0 8px 110px; line-height:1.5;">
259
+ Sets <code>Cache-Control: public, max-age=N</code> on the response.<br>
260
+ Safe for static assets. Avoid for dynamic or user-specific responses.<br>
261
+ Overridden by <code>msg.headers['cache-control']</code> if set in the flow.
262
+ </div>
225
263
  <div class="form-row" style="margin-bottom:0;">
226
- <label><i class="fa fa-list"></i> <span data-i18n="httpin.label.headers"></span></label>
264
+ <label><i class="fa fa-list"></i> Headers</label>
227
265
  </div>
228
266
  <div class="form-row node-input-headers-container-row">
229
267
  <ol id="node-input-headers-container"></ol>
230
268
  </div>
231
- <div class="form-tips"><span data-i18n="[html]httpin.tip.res"></span></div>
269
+ <div class="form-tips">The messages sent to this node <b>must</b> originate from an <i>http input</i> node</div>
232
270
  </script>
233
271
 
234
272
  <script type="text/javascript">
@@ -248,7 +286,9 @@
248
286
  uploadDir: {value: ''},
249
287
  authConfig: {type:"http.auth.config+", required:false},
250
288
  enableZod: {value: false},
251
- zodSchema: {value: ""}
289
+ zodSchema: {value: ""},
290
+ enableCache: {value: false},
291
+ cacheDuration: {value: 300}
252
292
  },
253
293
  inputs:0,
254
294
  outputs:1,
@@ -316,6 +356,12 @@
316
356
  $("#node-input-enableZod").on("change", toggleZodSchema);
317
357
  toggleZodSchema();
318
358
 
359
+ function toggleCacheDuration() {
360
+ $(".httpin-cache-duration-row").toggle($("#node-input-enableCache").is(":checked"));
361
+ }
362
+ $("#node-input-enableCache").on("change", toggleCacheDuration);
363
+ toggleCacheDuration();
364
+
319
365
  var httpRoot = (RED.settings.httpNodeRoot || "").replace(/\/$/, "");
320
366
  var docsUrl = window.location.origin + httpRoot + "/docs";
321
367
  var specUrl = window.location.origin + httpRoot + "/openapi.json";
@@ -367,7 +413,9 @@
367
413
  value:"",
368
414
  label: RED._("node-red:httpin.label.status"),
369
415
  validate: RED.validators.number(true)},
370
- headers: {value:{}}
416
+ headers: {value:{}},
417
+ enableCache: {value: false},
418
+ cacheDuration: {value: 3600}
371
419
  },
372
420
  inputs:1,
373
421
  outputs:0,
@@ -380,6 +428,12 @@
380
428
  return this.name?"node_label_italic":"";
381
429
  },
382
430
  oneditprepare: function() {
431
+ function toggleCacheDuration() {
432
+ $(".http-out-cache-duration-row").toggle($("#node-input-enableCache").is(":checked"));
433
+ }
434
+ $("#node-input-enableCache").on("change", toggleCacheDuration);
435
+ toggleCacheDuration();
436
+
383
437
  var headerList = $("#node-input-headers-container").css('min-height','150px').css('min-width','450px').editableList({
384
438
  addItem: function(container,i,header) {
385
439
  var row = $('<div/>').css({
package/httpin+.js CHANGED
@@ -38,7 +38,10 @@ module.exports = function(RED) {
38
38
  var jwt = require('jsonwebtoken');
39
39
  var jwksRsa = require('jwks-rsa');
40
40
  var { z } = require('zod');
41
- var os = require('os');
41
+ var os = require('os');
42
+ var path = require('path');
43
+ var fs = require('fs');
44
+ var crypto = require('crypto');
42
45
 
43
46
  function rawBodyParser(req, res, next) {
44
47
  if (req.skipRawBodyParser) { next(); } // don't parse this if told to skip
@@ -187,6 +190,29 @@ module.exports = function(RED) {
187
190
  RED.httpNode.options("*",corsHandler);
188
191
  }
189
192
 
193
+ // ── Backend cache utilities ────────────────────────────────────────────
194
+ function computeCacheKey(req) {
195
+ var query = req.query || {};
196
+ var sorted = Object.keys(query).sort().map(function(k) {
197
+ return encodeURIComponent(k) + '=' + encodeURIComponent(String(query[k]));
198
+ }).join('&');
199
+ return crypto.createHash('sha256').update(req.path + '?' + sorted).digest('hex');
200
+ }
201
+
202
+ function readCacheEntry(dir, key) {
203
+ try {
204
+ var data = JSON.parse(fs.readFileSync(path.join(dir, key + '.json'), 'utf8'));
205
+ return Date.now() < data.expires ? data : null;
206
+ } catch(e) { return null; }
207
+ }
208
+
209
+ function writeCacheEntry(dir, key, entry) {
210
+ try {
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ fs.writeFileSync(path.join(dir, key + '.json'), JSON.stringify(entry), 'utf8');
213
+ } catch(e) {}
214
+ }
215
+
190
216
  function HTTPIn(n) {
191
217
  RED.nodes.createNode(this,n);
192
218
 
@@ -255,6 +281,9 @@ module.exports = function(RED) {
255
281
  this.method = n.method;
256
282
  this.swaggerDoc = n.swaggerDoc;
257
283
  this.enableUpload = n.enableUpload;
284
+ this.enableCache = n.enableCache === true;
285
+ this.cacheDuration = parseInt(n.cacheDuration) || 300;
286
+ this.cacheDir = path.join(RED.settings.userDir || process.cwd(), 'cache', 'http+');
258
287
  this.uploadStorage = n.uploadStorage || 'memory';
259
288
  this.maxFileSize = n.maxFileSize || 5;
260
289
  this.uploadDir = n.uploadDir || '';
@@ -267,6 +296,24 @@ module.exports = function(RED) {
267
296
  };
268
297
 
269
298
  this.callback = function(req,res) {
299
+ // ── Backend cache hit check ────────────────────────────────
300
+ var isAuthFree = !authConfig || authConfig.authType === 'none';
301
+ if (node.enableCache && node.method === 'get' && isAuthFree) {
302
+ var cKey = computeCacheKey(req);
303
+ var hit = readCacheEntry(node.cacheDir, cKey);
304
+ if (hit) {
305
+ res.set('X-Cache', 'HIT');
306
+ if (hit.contentType) { res.set('Content-Type', hit.contentType); }
307
+ var hitBody = hit.bodyEncoding === 'base64'
308
+ ? Buffer.from(hit.body, 'base64')
309
+ : hit.body;
310
+ return res.status(hit.statusCode).send(hitBody);
311
+ }
312
+ req._httpPlusCacheKey = cKey;
313
+ req._httpPlusCacheTtl = node.cacheDuration;
314
+ req._httpPlusCacheDir = node.cacheDir;
315
+ }
316
+
270
317
  // Dispatch msg downstream after successful auth
271
318
  function dispatchMsg(user) {
272
319
  var msgid = RED.util.generateId();
@@ -531,6 +578,8 @@ module.exports = function(RED) {
531
578
  var node = this;
532
579
  this.headers = n.headers||{};
533
580
  this.statusCode = parseInt(n.statusCode);
581
+ this.enableCache = n.enableCache === true;
582
+ this.cacheDuration = parseInt(n.cacheDuration) || 3600;
534
583
  this.on("input",function(msg,_send,done) {
535
584
  if (msg.res) {
536
585
  var headers = RED.util.cloneMessage(node.headers);
@@ -551,6 +600,13 @@ module.exports = function(RED) {
551
600
  }
552
601
  }
553
602
  }
603
+ // ── Browser cache ──────────────────────────────────────────
604
+ if (node.enableCache && !headers.hasOwnProperty('cache-control')) {
605
+ headers['cache-control'] = 'public, max-age=' + node.cacheDuration;
606
+ }
607
+ // ── Backend cache write coordination ───────────────────────
608
+ var rawReq = msg.res._res && msg.res._res.req;
609
+ var httpPlusCacheKey = rawReq && rawReq._httpPlusCacheKey;
554
610
  // ── Streaming mode ─────────────────────────────────────────
555
611
  var stream = msg.stream || (msg.payload && typeof msg.payload.pipe === 'function' && !Buffer.isBuffer(msg.payload) ? msg.payload : null);
556
612
  if (stream) {
@@ -572,6 +628,9 @@ module.exports = function(RED) {
572
628
  return;
573
629
  }
574
630
 
631
+ if (httpPlusCacheKey) {
632
+ headers['x-cache'] = 'MISS';
633
+ }
575
634
  if (Object.keys(headers).length > 0) {
576
635
  msg.res._res.set(headers);
577
636
  }
@@ -596,6 +655,14 @@ module.exports = function(RED) {
596
655
  var statusCode = node.statusCode || parseInt(msg.statusCode) || 200;
597
656
  if (typeof msg.payload == "object" && !Buffer.isBuffer(msg.payload)) {
598
657
  msg.res._res.status(statusCode).jsonp(msg.payload);
658
+ if (httpPlusCacheKey) {
659
+ writeCacheEntry(rawReq._httpPlusCacheDir, httpPlusCacheKey, {
660
+ expires: Date.now() + rawReq._httpPlusCacheTtl * 1000,
661
+ statusCode: statusCode,
662
+ contentType: 'application/json',
663
+ body: JSON.stringify(msg.payload)
664
+ });
665
+ }
599
666
  } else {
600
667
  if (msg.res._res.get('content-length') == null) {
601
668
  var len;
@@ -615,6 +682,16 @@ module.exports = function(RED) {
615
682
  msg.payload = ""+msg.payload;
616
683
  }
617
684
  msg.res._res.status(statusCode).send(msg.payload);
685
+ if (httpPlusCacheKey) {
686
+ var isBuffer = Buffer.isBuffer(msg.payload);
687
+ writeCacheEntry(rawReq._httpPlusCacheDir, httpPlusCacheKey, {
688
+ expires: Date.now() + rawReq._httpPlusCacheTtl * 1000,
689
+ statusCode: statusCode,
690
+ contentType: msg.res._res.getHeader('content-type') || '',
691
+ body: isBuffer ? msg.payload.toString('base64') : String(msg.payload == null ? '' : msg.payload),
692
+ bodyEncoding: isBuffer ? 'base64' : 'text'
693
+ });
694
+ }
618
695
  }
619
696
  } else {
620
697
  node.warn(RED._("httpin.errors.no-response"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inteli.city/node-red-contrib-http-plus",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "dependencies": {
5
5
  "body-parser": "1.20.3",
6
6
  "content-type": "1.0.5",