@inteli.city/node-red-contrib-http-plus 1.0.0 → 1.0.2

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 (4) hide show
  1. package/README.md +136 -3
  2. package/httpin+.html +131 -13
  3. package/httpin+.js +116 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @inteli.city/node-red-contrib-http+
2
2
 
3
- Enhanced HTTP nodes for Node-RED with built-in authentication and request validation.
3
+ Enhanced HTTP nodes for Node-RED with built-in authentication, request validation, and caching.
4
4
 
5
5
  ---
6
6
 
@@ -21,6 +21,8 @@ These nodes are designed to be compatible with the standard Node-RED HTTP flow w
21
21
  | File upload handling | Basic | Structured (`msg.files`) |
22
22
  | Data normalization | Manual | Built-in via Zod transforms |
23
23
  | Streaming responses | Limited/manual | First-class support |
24
+ | Backend caching | Not available | Built-in (`http.in+`) |
25
+ | Browser cache control | Manual headers | Built-in (`http.out+`) |
24
26
 
25
27
  ---
26
28
 
@@ -57,6 +59,7 @@ Use standard nodes when:
57
59
  - [Nodes](#nodes)
58
60
  - [Install](#install)
59
61
  - [Core Concepts](#core-concepts)
62
+ - [Caching](#caching)
60
63
  - [Authentication](#authentication)
61
64
  - [Validation with Zod](#validation-with-zod)
62
65
  - [Swagger / OpenAPI](#swagger--openapi)
@@ -71,9 +74,9 @@ Use standard nodes when:
71
74
 
72
75
  | Node | Role |
73
76
  |------|------|
74
- | `http.in+` | Receives HTTP requests. Runs auth and Zod validation before sending `msg` downstream. |
77
+ | `http.in+` | Receives HTTP requests. Runs auth, Zod validation, and optional backend caching before sending `msg` downstream. |
75
78
  | `http.request+` | Makes outgoing HTTP requests. Drop-in replacement for the standard `http request` node. |
76
- | `http.out+` | Sends the HTTP response back to the caller. Works identically to the standard `http response` node. |
79
+ | `http.out+` | Sends the HTTP response back to the caller. Optionally controls browser caching via `Cache-Control`. |
77
80
 
78
81
  ## Install
79
82
 
@@ -110,6 +113,118 @@ inject ──→ http.request+ ──→ debug
110
113
 
111
114
  ---
112
115
 
116
+ ## Caching
117
+
118
+ http+ supports two distinct caching mechanisms that operate at different layers and solve different problems.
119
+
120
+ | Feature | Backend cache (`http.in+`) | Browser cache (`http.out+`) |
121
+ |---------|----------------------------|-----------------------------|
122
+ | Layer | Server | Client (browser) |
123
+ | When applied | Before flow execution | After response is sent |
124
+ | Effect | Skips the flow entirely on a hit | Browser may reuse the response |
125
+ | Default | Disabled | Disabled |
126
+ | Risk | Serving stale or incorrect data | Stale data in the browser |
127
+
128
+ They can be used independently or combined.
129
+
130
+ ### How they work
131
+
132
+ **Backend cache** runs at the entry point of the flow. If a valid cached response exists for the incoming request, it is returned immediately — no downstream node runs.
133
+
134
+ **Browser cache** runs at the response level. It tells the client how long to keep the response locally. The flow still runs on every server request; the browser decides whether to send the request at all.
135
+
136
+ ---
137
+
138
+ ### Backend cache (http.in+)
139
+
140
+ Caches full HTTP responses on the server. Repeated identical GET requests are served from cache without executing any downstream nodes.
141
+
142
+ Active only for `GET` requests. The **Cache** field controls the scope:
143
+
144
+ | Mode | Who shares the cache | Auth required |
145
+ |------|----------------------|---------------|
146
+ | Disabled | — | — |
147
+ | Public | All requests with the same URL + query | No |
148
+ | Per-user | Each authenticated user has their own entry | Yes |
149
+ | By claim | Users sharing the same value for a chosen identity field | Yes |
150
+
151
+ **Cache key** — derived from URL path + sorted query parameters + identity (for per-user and by-claim modes). Headers, cookies, and request body are not part of the key.
152
+
153
+ **Storage** — responses are stored on disk:
154
+
155
+ ```
156
+ <userDir>/cache/http+/
157
+ ```
158
+
159
+ The directory is created automatically if it does not exist.
160
+
161
+ **TTL** — controlled by the "Cache duration (s)" field. Expired entries are ignored and replaced on the next request.
162
+
163
+ **Observability** — every cached response includes:
164
+
165
+ ```
166
+ X-Cache: HIT → served from cache; flow was skipped
167
+ X-Cache: MISS → cache was empty or expired; flow ran
168
+ X-Cache-Scope: public | user | claim:<field>
169
+ ```
170
+
171
+ **Examples:**
172
+
173
+ ```
174
+ GET /products → Public cache (shared across all callers)
175
+ GET /profile → Per-user cache (isolated per authenticated user)
176
+ GET /dashboard → By claim: tenantId (shared per tenant, isolated between tenants)
177
+ ```
178
+
179
+ ---
180
+
181
+ ### Browser cache (http.out+)
182
+
183
+ Instructs the browser to store the response locally and reuse it for subsequent requests. The server still handles every request that reaches it — the browser controls whether a request is sent at all.
184
+
185
+ **What it does:**
186
+
187
+ When enabled, `http.out+` adds the following header to every response:
188
+
189
+ ```
190
+ Cache-Control: public, max-age=<duration>
191
+ ```
192
+
193
+ **Override rule:** if `msg.headers['cache-control']` is set anywhere in the flow, it takes precedence over the node setting. This allows per-request control without changing node configuration.
194
+
195
+ **Example:**
196
+
197
+ ```
198
+ [http.in+ GET /logo.png] ──→ [read file] ──→ [http.out+ browser cache: 86400 s]
199
+ ```
200
+
201
+ The browser caches the image for 24 hours. On subsequent page loads, no network request is made.
202
+
203
+ #### ⚠️ When NOT to use browser cache
204
+
205
+ - Dynamic responses that change between requests
206
+ - User-specific or session-specific data
207
+ - Any endpoint where a stale cached response would cause incorrect behavior
208
+
209
+ ---
210
+
211
+ ### Using both together
212
+
213
+ Backend cache and browser cache can be combined on the same endpoint:
214
+
215
+ ```
216
+ [http.in+ GET /summary cache: 60 s] ──→ [compute] ──→ [http.out+ browser cache: 60 s]
217
+ ```
218
+
219
+ - The server caches the response for 60 seconds — the flow is skipped on repeated hits.
220
+ - The browser also caches it for 60 seconds — the request may not leave the client at all.
221
+
222
+ Align both TTLs to avoid a situation where the browser caches a response longer than the server would serve the same stale version.
223
+
224
+ For highly dynamic data, leave both disabled.
225
+
226
+ ---
227
+
113
228
  ## Authentication
114
229
 
115
230
  Configure authentication by creating an **`http.auth.config+`** config node and attaching it to `http.in+`.
@@ -489,6 +604,24 @@ Sends the HTTP response back to the original caller. Must be connected to the sa
489
604
 
490
605
  If `msg.payload` is an object, the response is sent as JSON automatically.
491
606
 
607
+ ### Browser caching
608
+
609
+ `http.out+` can automatically set browser cache headers without requiring a function node.
610
+
611
+ Enable **Enable browser cache** in the node editor and set **Cache duration (s)**. The node will add:
612
+
613
+ ```
614
+ Cache-Control: public, max-age=<duration>
615
+ ```
616
+
617
+ This does not affect server-side execution — the flow always runs when a request reaches the server. The browser uses the header to decide whether to skip the request entirely on subsequent calls.
618
+
619
+ If `msg.headers['cache-control']` is set anywhere in the flow, it overrides the node setting. This allows per-request cache control without changing node configuration.
620
+
621
+ Use for static or infrequently-changing responses (images, scripts, reference data). Avoid for user-specific or time-sensitive responses.
622
+
623
+ For a full explanation including TTL guidance, safety rules, and combining with backend cache, see the [Caching](#caching) section.
624
+
492
625
  ### Streaming responses
493
626
 
494
627
  You can stream a response instead of sending a buffer. Useful for large files or when proxying a stream from another source.
package/httpin+.html CHANGED
@@ -80,6 +80,21 @@
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>Cache modes</h3>
84
+ <p>The <b>Cache</b> selector controls how GET responses are cached on the server. Cached responses skip the flow entirely.</p>
85
+ <dl>
86
+ <dt>Disabled</dt>
87
+ <dd>No caching. Every request executes the full flow.</dd>
88
+ <dt>Public</dt>
89
+ <dd>One cached response is shared for all requests with the same URL and query parameters. Authentication is ignored — do not use for endpoints that return user-specific data.</dd>
90
+ <dt>Per-user</dt>
91
+ <dd>Each authenticated user has their own cache entry. Requires an auth config node. Responses are never shared between users.</dd>
92
+ <dt>By claim</dt>
93
+ <dd>Cache entries are grouped by the value of a specific identity field (e.g. <code>tenantId</code>, <code>role</code>). Users sharing the same value receive the same cached response.</dd>
94
+ </dl>
95
+ <p>Cache files are stored in <code>&lt;userDir&gt;/cache/http+/</code>. Expired entries are evicted lazily.</p>
96
+ <p>Response headers: <code>X-Cache: HIT | MISS</code> and <code>X-Cache-Scope: public | user | claim:&lt;field&gt;</code>.</p>
97
+
83
98
  <h3>Details</h3>
84
99
  <p>The URL field supports Express route patterns, including named parameters such as <code>/users/:id</code>.</p>
85
100
  <p>For more examples see the package <a href="https://github.com/inteli-city/node-red-contrib-http-plus">README</a>.</p>
@@ -104,14 +119,15 @@
104
119
 
105
120
  <h3>Details</h3>
106
121
  <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>
122
+ <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
123
  <p>The <code>msg.res</code> object from the originating <b>http.in+</b> must reach this node unchanged.</p>
109
124
 
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>
125
+ <h3>Browser Cache</h3>
126
+ <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
127
  <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>
128
+ <li>Safe for static assets (images, scripts, stylesheets).</li>
129
+ <li>Avoid for dynamic or user-specific responses.</li>
130
+ <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
131
  </ul>
116
132
 
117
133
  <h3>Streaming</h3>
@@ -148,6 +164,30 @@
148
164
  <label for="node-input-swaggerDoc"><i class="fa fa-file-text-o"></i> <span data-i18n="httpin.label.doc"></span></label>
149
165
  <input type="text" id="node-input-swaggerDoc">
150
166
  </div>
167
+ <div class="form-row" style="margin-top:10px; margin-bottom:4px; border-top:1px solid #e6e6e6; padding-top:8px;">
168
+ <span style="font-size:11px; color:#bbb; text-transform:uppercase; letter-spacing:0.05em;">Execution</span>
169
+ </div>
170
+ <div class="form-row">
171
+ <label for="node-input-cacheMode"><i class="fa fa-database"></i> Cache</label>
172
+ <select id="node-input-cacheMode" style="width:70%;">
173
+ <option value="disabled">Disabled</option>
174
+ <option value="public">Public</option>
175
+ <option value="per-user">Per-user</option>
176
+ <option value="by-claim">By claim</option>
177
+ </select>
178
+ </div>
179
+ <div id="node-input-cache-mode-help" style="font-size:12px; color:#666; margin: -4px 0 8px 110px; line-height:1.5;"></div>
180
+ <div class="form-row httpin-cache-claim-row">
181
+ <label for="node-input-cacheClaim"><i class="fa fa-key"></i> Claim</label>
182
+ <input type="text" id="node-input-cacheClaim" style="width:70%;" placeholder="e.g. tenantId, role">
183
+ </div>
184
+ <div class="httpin-cache-claim-help-row" style="font-size:12px; color:#666; margin: -4px 0 8px 110px; line-height:1.5;">
185
+ Choose a stable identity field that determines how responses can be shared.
186
+ </div>
187
+ <div class="form-row httpin-cache-duration-row">
188
+ <label for="node-input-cacheDuration"><i class="fa fa-clock-o"></i> Cache duration (s)</label>
189
+ <input type="number" id="node-input-cacheDuration" style="width:100px;" min="1" placeholder="300">
190
+ </div>
151
191
  <div class="form-row upload-section">
152
192
  <input type="checkbox" id="node-input-enableUpload" style="display:inline-block; width:auto; vertical-align:top;">
153
193
  <label for="node-input-enableUpload" style="width:auto;"><i class="fa fa-upload"></i> Enable file uploads</label>
@@ -215,20 +255,33 @@
215
255
 
216
256
  <script type="text/html" data-template-name="http.out+">
217
257
  <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">
258
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
259
+ <input type="text" id="node-input-name" placeholder="Name">
220
260
  </div>
221
261
  <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>
262
+ <label for="node-input-statusCode"><i class="fa fa-long-arrow-left"></i> Status code</label>
223
263
  <input type="text" id="node-input-statusCode" placeholder="msg.statusCode">
224
264
  </div>
265
+ <div class="form-row">
266
+ <input type="checkbox" id="node-input-enableCache" style="display:inline-block; width:auto; vertical-align:top;">
267
+ <label for="node-input-enableCache" style="width:auto;"> Enable browser cache</label>
268
+ </div>
269
+ <div class="form-row http-out-cache-duration-row">
270
+ <label for="node-input-cacheDuration"><i class="fa fa-clock-o"></i> Cache duration (s)</label>
271
+ <input type="number" id="node-input-cacheDuration" style="width:100px;" min="1" placeholder="3600">
272
+ </div>
273
+ <div class="http-out-cache-duration-row" style="font-size:12px; color:#666; margin: -4px 0 8px 110px; line-height:1.5;">
274
+ Sets <code>Cache-Control: public, max-age=N</code> on the response.<br>
275
+ Safe for static assets. Avoid for dynamic or user-specific responses.<br>
276
+ Overridden by <code>msg.headers['cache-control']</code> if set in the flow.
277
+ </div>
225
278
  <div class="form-row" style="margin-bottom:0;">
226
- <label><i class="fa fa-list"></i> <span data-i18n="httpin.label.headers"></span></label>
279
+ <label><i class="fa fa-list"></i> Headers</label>
227
280
  </div>
228
281
  <div class="form-row node-input-headers-container-row">
229
282
  <ol id="node-input-headers-container"></ol>
230
283
  </div>
231
- <div class="form-tips"><span data-i18n="[html]httpin.tip.res"></span></div>
284
+ <div class="form-tips">The messages sent to this node <b>must</b> originate from an <i>http input</i> node</div>
232
285
  </script>
233
286
 
234
287
  <script type="text/javascript">
@@ -248,7 +301,10 @@
248
301
  uploadDir: {value: ''},
249
302
  authConfig: {type:"http.auth.config+", required:false},
250
303
  enableZod: {value: false},
251
- zodSchema: {value: ""}
304
+ zodSchema: {value: ""},
305
+ cacheMode: {value: 'disabled'},
306
+ cacheClaim: {value: ''},
307
+ cacheDuration: {value: 300}
252
308
  },
253
309
  inputs:0,
254
310
  outputs:1,
@@ -316,6 +372,45 @@
316
372
  $("#node-input-enableZod").on("change", toggleZodSchema);
317
373
  toggleZodSchema();
318
374
 
375
+ var cacheModeHelp = {
376
+ 'disabled': '',
377
+ 'public': 'Same response is reused for all requests with the same URL and query parameters.<br>Authentication is ignored — do not use for endpoints that return user-specific data.',
378
+ 'per-user': 'Each authenticated user receives their own cached response.<br>Responses are never shared between users.',
379
+ 'by-claim': 'Responses are shared between users with the same value for the selected field.<br>Use fields that represent data scope (e.g. tenantId, role).'
380
+ };
381
+ function updateCacheUI() {
382
+ var mode = $("#node-input-cacheMode").val();
383
+ var help = cacheModeHelp[mode] || '';
384
+ $("#node-input-cache-mode-help").html(help).toggle(help !== '');
385
+ $(".httpin-cache-claim-row").toggle(mode === 'by-claim');
386
+ $(".httpin-cache-claim-help-row").toggle(mode === 'by-claim');
387
+ $(".httpin-cache-duration-row").toggle(mode !== 'disabled');
388
+ }
389
+ function updateAuthCacheOptions() {
390
+ var authNodeId = $("#node-input-authConfig").val();
391
+ var authNode = RED.nodes.node(authNodeId);
392
+ var hasAuth = authNode && authNode.authType && authNode.authType !== 'none';
393
+ var perUser = $("#node-input-cacheMode option[value='per-user']");
394
+ var byClaim = $("#node-input-cacheMode option[value='by-claim']");
395
+ if (!hasAuth) {
396
+ perUser.prop('disabled', true);
397
+ byClaim.prop('disabled', true);
398
+ var m = $("#node-input-cacheMode").val();
399
+ if (m === 'per-user' || m === 'by-claim') {
400
+ $("#node-input-cacheMode").val('disabled');
401
+ }
402
+ } else {
403
+ perUser.prop('disabled', false);
404
+ byClaim.prop('disabled', false);
405
+ }
406
+ updateCacheUI();
407
+ }
408
+ // Backward compat: old flows had enableCache=true, migrate to public
409
+ if (this.cacheMode === 'disabled' && this.enableCache === true) {
410
+ $("#node-input-cacheMode").val('public');
411
+ }
412
+ $("#node-input-cacheMode").on("change", updateCacheUI);
413
+
319
414
  var httpRoot = (RED.settings.httpNodeRoot || "").replace(/\/$/, "");
320
415
  var docsUrl = window.location.origin + httpRoot + "/docs";
321
416
  var specUrl = window.location.origin + httpRoot + "/openapi.json";
@@ -337,8 +432,23 @@
337
432
  var content = authHelpContent[type] || authHelpContent["none"];
338
433
  $("#node-input-auth-help").html(content).show();
339
434
  }
340
- $("#node-input-authConfig").on("change", updateAuthHelp);
435
+ $("#node-input-authConfig").on("change", function() {
436
+ updateAuthHelp();
437
+ updateAuthCacheOptions();
438
+ });
341
439
  updateAuthHelp();
440
+ updateAuthCacheOptions();
441
+ },
442
+ oneditvalidate: function() {
443
+ if ($("#node-input-cacheMode").val() === 'by-claim') {
444
+ var claim = $("#node-input-cacheClaim").val().trim();
445
+ if (!claim) {
446
+ $("#node-input-cacheClaim").css('outline', '2px solid #d9534f');
447
+ return false;
448
+ }
449
+ }
450
+ $("#node-input-cacheClaim").css('outline', '');
451
+ return true;
342
452
  }
343
453
 
344
454
  });
@@ -367,7 +477,9 @@
367
477
  value:"",
368
478
  label: RED._("node-red:httpin.label.status"),
369
479
  validate: RED.validators.number(true)},
370
- headers: {value:{}}
480
+ headers: {value:{}},
481
+ enableCache: {value: false},
482
+ cacheDuration: {value: 3600}
371
483
  },
372
484
  inputs:1,
373
485
  outputs:0,
@@ -380,6 +492,12 @@
380
492
  return this.name?"node_label_italic":"";
381
493
  },
382
494
  oneditprepare: function() {
495
+ function toggleCacheDuration() {
496
+ $(".http-out-cache-duration-row").toggle($("#node-input-enableCache").is(":checked"));
497
+ }
498
+ $("#node-input-enableCache").on("change", toggleCacheDuration);
499
+ toggleCacheDuration();
500
+
383
501
  var headerList = $("#node-input-headers-container").css('min-height','150px').css('min-width','450px').editableList({
384
502
  addItem: function(container,i,header) {
385
503
  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,46 @@ module.exports = function(RED) {
187
190
  RED.httpNode.options("*",corsHandler);
188
191
  }
189
192
 
193
+ // ── Backend cache utilities ────────────────────────────────────────────
194
+ function computeCacheKey(req, identity) {
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
+ var base = req.path + '?' + sorted;
200
+ if (identity) { base += '\0' + identity; }
201
+ return crypto.createHash('sha256').update(base).digest('hex');
202
+ }
203
+
204
+ function userToIdentity(user) {
205
+ if (user === undefined || user === null) { return null; }
206
+ if (typeof user === 'string') { return user; }
207
+ return Object.keys(user).sort().map(function(k) {
208
+ return k + '=' + String(user[k]);
209
+ }).join('|');
210
+ }
211
+
212
+ function getCacheScope(cacheMode, cacheClaim) {
213
+ if (cacheMode === 'public') { return 'public'; }
214
+ if (cacheMode === 'per-user') { return 'user'; }
215
+ if (cacheMode === 'by-claim') { return 'claim:' + (cacheClaim || ''); }
216
+ return null;
217
+ }
218
+
219
+ function readCacheEntry(dir, key) {
220
+ try {
221
+ var data = JSON.parse(fs.readFileSync(path.join(dir, key + '.json'), 'utf8'));
222
+ return Date.now() < data.expires ? data : null;
223
+ } catch(e) { return null; }
224
+ }
225
+
226
+ function writeCacheEntry(dir, key, entry) {
227
+ try {
228
+ fs.mkdirSync(dir, { recursive: true });
229
+ fs.writeFileSync(path.join(dir, key + '.json'), JSON.stringify(entry), 'utf8');
230
+ } catch(e) {}
231
+ }
232
+
190
233
  function HTTPIn(n) {
191
234
  RED.nodes.createNode(this,n);
192
235
 
@@ -255,6 +298,11 @@ module.exports = function(RED) {
255
298
  this.method = n.method;
256
299
  this.swaggerDoc = n.swaggerDoc;
257
300
  this.enableUpload = n.enableUpload;
301
+ var legacyCacheEnabled = n.enableCache === true && !n.cacheMode;
302
+ this.cacheMode = n.cacheMode || (legacyCacheEnabled ? 'public' : 'disabled');
303
+ this.cacheClaim = (n.cacheClaim || '').trim();
304
+ this.cacheDuration = parseInt(n.cacheDuration) || 300;
305
+ this.cacheDir = path.join(RED.settings.userDir || process.cwd(), 'cache', 'http+');
258
306
  this.uploadStorage = n.uploadStorage || 'memory';
259
307
  this.maxFileSize = n.maxFileSize || 5;
260
308
  this.uploadDir = n.uploadDir || '';
@@ -269,6 +317,40 @@ module.exports = function(RED) {
269
317
  this.callback = function(req,res) {
270
318
  // Dispatch msg downstream after successful auth
271
319
  function dispatchMsg(user) {
320
+ // ── Cache hit check (runs after auth, mode-aware) ──────
321
+ if (node.cacheMode !== 'disabled' && node.method === 'get') {
322
+ var shouldCache = false;
323
+ var identity = null;
324
+ if (node.cacheMode === 'public') {
325
+ shouldCache = true;
326
+ } else if (node.cacheMode === 'per-user') {
327
+ identity = userToIdentity(user);
328
+ shouldCache = identity !== null;
329
+ } else if (node.cacheMode === 'by-claim') {
330
+ if (user && typeof user === 'object' && node.cacheClaim) {
331
+ var cv = user[node.cacheClaim];
332
+ if (cv !== undefined) { identity = String(cv); shouldCache = true; }
333
+ }
334
+ }
335
+ if (shouldCache) {
336
+ var cKey = computeCacheKey(req, identity);
337
+ var hit = readCacheEntry(node.cacheDir, cKey);
338
+ if (hit) {
339
+ var scope = getCacheScope(node.cacheMode, node.cacheClaim);
340
+ res.set('X-Cache', 'HIT');
341
+ if (scope) { res.set('X-Cache-Scope', scope); }
342
+ if (hit.contentType) { res.set('Content-Type', hit.contentType); }
343
+ var hitBody = hit.bodyEncoding === 'base64'
344
+ ? Buffer.from(hit.body, 'base64') : hit.body;
345
+ return res.status(hit.statusCode).send(hitBody);
346
+ }
347
+ req._httpPlusCacheKey = cKey;
348
+ req._httpPlusCacheTtl = node.cacheDuration;
349
+ req._httpPlusCacheDir = node.cacheDir;
350
+ req._httpPlusCacheScope = getCacheScope(node.cacheMode, node.cacheClaim);
351
+ }
352
+ }
353
+
272
354
  var msgid = RED.util.generateId();
273
355
  res._msgid = msgid;
274
356
  // Since Node 15, req.headers are lazily computed and the property
@@ -531,6 +613,8 @@ module.exports = function(RED) {
531
613
  var node = this;
532
614
  this.headers = n.headers||{};
533
615
  this.statusCode = parseInt(n.statusCode);
616
+ this.enableCache = n.enableCache === true;
617
+ this.cacheDuration = parseInt(n.cacheDuration) || 3600;
534
618
  this.on("input",function(msg,_send,done) {
535
619
  if (msg.res) {
536
620
  var headers = RED.util.cloneMessage(node.headers);
@@ -551,6 +635,13 @@ module.exports = function(RED) {
551
635
  }
552
636
  }
553
637
  }
638
+ // ── Browser cache ──────────────────────────────────────────
639
+ if (node.enableCache && !headers.hasOwnProperty('cache-control')) {
640
+ headers['cache-control'] = 'public, max-age=' + node.cacheDuration;
641
+ }
642
+ // ── Backend cache write coordination ───────────────────────
643
+ var rawReq = msg.res._res && msg.res._res.req;
644
+ var httpPlusCacheKey = rawReq && rawReq._httpPlusCacheKey;
554
645
  // ── Streaming mode ─────────────────────────────────────────
555
646
  var stream = msg.stream || (msg.payload && typeof msg.payload.pipe === 'function' && !Buffer.isBuffer(msg.payload) ? msg.payload : null);
556
647
  if (stream) {
@@ -572,6 +663,12 @@ module.exports = function(RED) {
572
663
  return;
573
664
  }
574
665
 
666
+ if (httpPlusCacheKey) {
667
+ headers['x-cache'] = 'MISS';
668
+ if (rawReq._httpPlusCacheScope) {
669
+ headers['x-cache-scope'] = rawReq._httpPlusCacheScope;
670
+ }
671
+ }
575
672
  if (Object.keys(headers).length > 0) {
576
673
  msg.res._res.set(headers);
577
674
  }
@@ -596,6 +693,14 @@ module.exports = function(RED) {
596
693
  var statusCode = node.statusCode || parseInt(msg.statusCode) || 200;
597
694
  if (typeof msg.payload == "object" && !Buffer.isBuffer(msg.payload)) {
598
695
  msg.res._res.status(statusCode).jsonp(msg.payload);
696
+ if (httpPlusCacheKey) {
697
+ writeCacheEntry(rawReq._httpPlusCacheDir, httpPlusCacheKey, {
698
+ expires: Date.now() + rawReq._httpPlusCacheTtl * 1000,
699
+ statusCode: statusCode,
700
+ contentType: 'application/json',
701
+ body: JSON.stringify(msg.payload)
702
+ });
703
+ }
599
704
  } else {
600
705
  if (msg.res._res.get('content-length') == null) {
601
706
  var len;
@@ -615,6 +720,16 @@ module.exports = function(RED) {
615
720
  msg.payload = ""+msg.payload;
616
721
  }
617
722
  msg.res._res.status(statusCode).send(msg.payload);
723
+ if (httpPlusCacheKey) {
724
+ var isBuffer = Buffer.isBuffer(msg.payload);
725
+ writeCacheEntry(rawReq._httpPlusCacheDir, httpPlusCacheKey, {
726
+ expires: Date.now() + rawReq._httpPlusCacheTtl * 1000,
727
+ statusCode: statusCode,
728
+ contentType: msg.res._res.getHeader('content-type') || '',
729
+ body: isBuffer ? msg.payload.toString('base64') : String(msg.payload == null ? '' : msg.payload),
730
+ bodyEncoding: isBuffer ? 'base64' : 'text'
731
+ });
732
+ }
618
733
  }
619
734
  } else {
620
735
  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.2",
4
4
  "dependencies": {
5
5
  "body-parser": "1.20.3",
6
6
  "content-type": "1.0.5",