@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.
- package/README.md +136 -3
- package/httpin+.html +131 -13
- package/httpin+.js +116 -1
- 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
|
|
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
|
|
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.
|
|
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><userDir>/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:<field></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
|
|
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>
|
|
111
|
-
<p>
|
|
125
|
+
<h3>Browser Cache</h3>
|
|
126
|
+
<p>Enable <b>Enable browser cache</b> to automatically set <code>Cache-Control: public, max-age=<seconds></code> on every response.</p>
|
|
112
127
|
<ul>
|
|
113
|
-
<li>
|
|
114
|
-
<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>
|
|
219
|
-
<input type="text" id="node-input-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>
|
|
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>
|
|
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"
|
|
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",
|
|
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
|
|
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"));
|