fable 3.1.70 → 3.1.72

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fable",
3
- "version": "3.1.70",
3
+ "version": "3.1.72",
4
4
  "description": "A service dependency injection, configuration and logging library.",
5
5
  "main": "source/Fable.js",
6
6
  "scripts": {
@@ -52,7 +52,7 @@
52
52
  "homepage": "https://github.com/stevenvelozo/fable",
53
53
  "devDependencies": {
54
54
  "pict-docuserve": "^0.1.5",
55
- "quackage": "^1.1.0"
55
+ "quackage": "^1.2.3"
56
56
  },
57
57
  "dependencies": {
58
58
  "async.eachlimit": "^0.5.2",
@@ -65,7 +65,7 @@
65
65
  "fable-log": "^3.0.18",
66
66
  "fable-serviceproviderbase": "^3.0.19",
67
67
  "fable-settings": "^3.0.16",
68
- "fable-uuid": "^3.0.13",
68
+ "fable-uuid": "^3.0.14",
69
69
  "manyfest": "^1.0.49",
70
70
  "simple-get": "^4.0.1"
71
71
  }
@@ -27,42 +27,83 @@ class FableServiceRestClient extends libFableServiceBase
27
27
  // of the request options before they are passed to the request library.
28
28
  this.prepareRequestOptions = (pOptions) => { return pOptions; };
29
29
 
30
- // Check for keep-alive configuration (from options or fable settings)
31
- // Useful in environments where connections are slow to establish
32
- // (e.g. inside poorly configured customer networks / VPNs)
33
- let tmpKeepAlive = this.options.KeepAlive || this.fable.settings.RestClientKeepAlive;
30
+ // Default per-request timeout (ms). Applied in preRequest when a caller
31
+ // does not supply their own. Node 20+ installs a ~5s socket timeout on
32
+ // http.globalAgent that aborts legitimately long-running requests; any
33
+ // explicit `timeout` on the request options takes that default out of
34
+ // play. See the "Request Timeout" test suite for the behaviors covered.
35
+ if (typeof this.options.RequestTimeout === 'number')
36
+ {
37
+ this.defaultRequestTimeout = this.options.RequestTimeout;
38
+ }
39
+ else if (typeof this.fable.settings.RestClientRequestTimeout === 'number')
40
+ {
41
+ this.defaultRequestTimeout = this.fable.settings.RestClientRequestTimeout;
42
+ }
43
+ else
44
+ {
45
+ this.defaultRequestTimeout = 60000;
46
+ }
47
+
48
+ // Always install our own http/https agents so every request bypasses
49
+ // http.globalAgent (and its Node 20+ mystery socket timeout). The
50
+ // KeepAlive flag only controls whether keepAlive is enabled on our own
51
+ // agents, not whether we have agents at all. Additional tuning
52
+ // (maxSockets, agent timeout, etc.) flows through KeepAliveAgentOptions.
53
+ let tmpKeepAlive = Boolean(this.options.KeepAlive || this.fable.settings.RestClientKeepAlive);
54
+ let tmpAgentOptions = Object.assign({}, this.options.KeepAliveAgentOptions);
34
55
  if (tmpKeepAlive)
35
56
  {
36
- this.initializeKeepAliveAgent(this.options.KeepAliveAgentOptions);
57
+ tmpAgentOptions.keepAlive = true;
37
58
  }
59
+ this._installHttpAgents(tmpAgentOptions);
38
60
  }
39
61
 
40
62
  /**
41
63
  * Initialize HTTP keep-alive agents and wire them into prepareRequestOptions.
42
- * Creates both an HTTP and HTTPS agent so the correct one is selected per-request
43
- * based on the URL protocol.
64
+ * Back-compat entry point: always forces keepAlive on. Prefer configuring
65
+ * the RestClient via the KeepAlive / KeepAliveAgentOptions constructor
66
+ * options instead of calling this method directly.
44
67
  *
45
68
  * @param {Object} [pAgentOptions] - Additional options passed to the Http/Https Agent constructors (e.g. timeout).
46
69
  */
47
70
  initializeKeepAliveAgent(pAgentOptions)
48
71
  {
49
72
  let tmpAgentOptions = Object.assign({ keepAlive: true }, pAgentOptions);
73
+ this._installHttpAgents(tmpAgentOptions);
74
+ }
50
75
 
51
- this.httpAgent = new libHttp.Agent(tmpAgentOptions);
52
- this.httpsAgent = new libHttps.Agent(tmpAgentOptions);
76
+ /**
77
+ * Construct http/https Agents from the given options and wire them into
78
+ * prepareRequestOptions so every request carries an explicit agent.
79
+ *
80
+ * @param {Object} pAgentOptions - Options passed directly to the Http/Https Agent constructors.
81
+ * @private
82
+ */
83
+ _installHttpAgents(pAgentOptions)
84
+ {
85
+ this.httpAgent = new libHttp.Agent(pAgentOptions);
86
+ this.httpsAgent = new libHttps.Agent(pAgentOptions);
53
87
 
54
88
  // Capture any previously set prepareRequestOptions so we can chain
55
89
  let tmpPreviousPrepareRequestOptions = this.prepareRequestOptions;
56
90
 
57
91
  this.prepareRequestOptions = (pOptions) =>
58
92
  {
59
- if (typeof pOptions.url === 'string' && pOptions.url.startsWith('http:'))
93
+ // Mirror simple-get's protocol decision exactly: it routes through
94
+ // the https module if and only if the parsed URL protocol is
95
+ // exactly 'https:'. Everything else — http: URLs, relative URLs
96
+ // (which simple-get treats as no-host http requests), and option
97
+ // objects with no .url at all — goes through the http module.
98
+ // Stamping an httpsAgent on an http request makes Node throw
99
+ // ERR_INVALID_PROTOCOL when http.request validates agent.protocol.
100
+ if (typeof pOptions.url === 'string' && pOptions.url.startsWith('https:'))
60
101
  {
61
- pOptions.agent = this.httpAgent;
102
+ pOptions.agent = this.httpsAgent;
62
103
  }
63
104
  else
64
105
  {
65
- pOptions.agent = this.httpsAgent;
106
+ pOptions.agent = this.httpAgent;
66
107
  }
67
108
  return tmpPreviousPrepareRequestOptions(pOptions);
68
109
  };
@@ -103,9 +144,207 @@ class FableServiceRestClient extends libFableServiceBase
103
144
  tmpOptions.url = this.fable.settings.RestClientURLPrefix + tmpOptions.url;
104
145
  }
105
146
 
147
+ // Apply the default request timeout when the caller hasn't supplied
148
+ // one. Setting any numeric value (including 0) suppresses the Node 20+
149
+ // http.globalAgent ~5s socket timeout.
150
+ if (typeof tmpOptions.timeout !== 'number')
151
+ {
152
+ tmpOptions.timeout = this.defaultRequestTimeout;
153
+ }
154
+
106
155
  return this.prepareRequestOptions(tmpOptions);
107
156
  }
108
157
 
158
+ /**
159
+ * Extract the hostname from a URL string. Returns null for relative URLs
160
+ * or anything else that doesn't parse cleanly.
161
+ *
162
+ * @private
163
+ * @param {string} pUrl
164
+ * @return {string|null}
165
+ */
166
+ _parseHostname(pUrl)
167
+ {
168
+ if (typeof pUrl !== 'string')
169
+ {
170
+ return null;
171
+ }
172
+ try
173
+ {
174
+ return new URL(pUrl).hostname || null;
175
+ }
176
+ catch (e)
177
+ {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Resolve a Location header against the URL of the request that produced
184
+ * it. Handles absolute Locations (returned as-is) and RFC 7231-compliant
185
+ * relative Locations (resolved against the current URL).
186
+ *
187
+ * @private
188
+ * @param {string} pCurrentURL - The URL of the request that produced the redirect.
189
+ * @param {string} pLocation - The Location header value.
190
+ * @return {string}
191
+ */
192
+ _resolveRedirectURL(pCurrentURL, pLocation)
193
+ {
194
+ if (typeof pLocation !== 'string' || pLocation.length === 0)
195
+ {
196
+ return pLocation;
197
+ }
198
+ try
199
+ {
200
+ return new URL(pLocation, pCurrentURL).toString();
201
+ }
202
+ catch (e)
203
+ {
204
+ // Either pCurrentURL is itself relative or pLocation is malformed.
205
+ // Fall back to passing it through verbatim — simple-get's parser
206
+ // will give the next hop a final say.
207
+ return pLocation;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Build the options object for the next hop of a redirect chain. Applies
213
+ * the same hop-rewrite rules simple-get does, plus an RFC-correct relative
214
+ * Location resolution that simple-get itself doesn't do:
215
+ * - Resolve the Location against the current URL (absolute or relative).
216
+ * - Strip simple-get's URL-derived state (protocol/hostname/port/path/auth)
217
+ * so the next hop re-parses the URL cleanly.
218
+ * - Drop the host header (re-derived from the new URL by simple-get).
219
+ * - Cross-origin: drop cookie + authorization to prevent leak.
220
+ * - 301/302 + POST: switch to GET, drop body and content headers.
221
+ *
222
+ * @private
223
+ * @param {Object} pOptions - The options used for the previous hop (post-simple-get mutation).
224
+ * @param {import('http').IncomingMessage} pResponse - The 3xx response.
225
+ * @param {string|undefined} pOriginalURL - The URL of the previous hop, captured before simple-get deleted it.
226
+ * @param {string|null} pOriginalHost - The hostname of the previous hop, for cross-origin detection.
227
+ * @return {Object}
228
+ */
229
+ _buildRedirectedOptions(pOptions, pResponse, pOriginalURL, pOriginalHost)
230
+ {
231
+ const tmpNew = Object.assign({}, pOptions);
232
+ tmpNew.url = this._resolveRedirectURL(pOriginalURL, pResponse.headers.location);
233
+
234
+ // Strip simple-get's own URL-derived fields so the next call re-parses cleanly.
235
+ delete tmpNew.protocol;
236
+ delete tmpNew.hostname;
237
+ delete tmpNew.port;
238
+ delete tmpNew.path;
239
+ delete tmpNew.auth;
240
+
241
+ // We set followRedirects=false on the previous hop to disable
242
+ // simple-get's auto-follow; that's our own internal flag, not caller
243
+ // intent. Drop it so the recursive _executeWithRedirects entry treats
244
+ // the next hop as another redirect-following call.
245
+ delete tmpNew.followRedirects;
246
+
247
+ // Headers — clone (don't mutate the caller's) and prune.
248
+ if (tmpNew.headers)
249
+ {
250
+ tmpNew.headers = Object.assign({}, tmpNew.headers);
251
+ delete tmpNew.headers.host;
252
+ }
253
+
254
+ // Cross-origin redirect: drop cookie and authorization to prevent leak (matches simple-get #73).
255
+ const tmpRedirectHost = this._parseHostname(tmpNew.url);
256
+ if (tmpRedirectHost !== null && tmpRedirectHost !== pOriginalHost && tmpNew.headers)
257
+ {
258
+ delete tmpNew.headers.cookie;
259
+ delete tmpNew.headers.authorization;
260
+ }
261
+
262
+ // 301/302 + POST → GET (matches simple-get #35 and RFC 7231 §6.4.2/6.4.3).
263
+ // Body and content headers come off; 307/308 preserve method/body so we leave them alone.
264
+ if (tmpNew.method === 'POST' && (pResponse.statusCode === 301 || pResponse.statusCode === 302))
265
+ {
266
+ tmpNew.method = 'GET';
267
+ if (tmpNew.headers)
268
+ {
269
+ delete tmpNew.headers['content-length'];
270
+ delete tmpNew.headers['content-type'];
271
+ }
272
+ delete tmpNew.body;
273
+ delete tmpNew.form;
274
+ }
275
+
276
+ return tmpNew;
277
+ }
278
+
279
+ /**
280
+ * Dispatch a request via simple-get, transparently following 3xx redirects
281
+ * until a non-redirect response or hard error.
282
+ *
283
+ * Why we drive the loop ourselves: simple-get's own redirect path
284
+ * (index.js lines 50-69) recurses with the original opts.agent intact, so
285
+ * an http→https redirect ends up calling https.request with an httpAgent
286
+ * (or vice versa) and Node throws ERR_INVALID_PROTOCOL synchronously.
287
+ * By disabling simple-get's auto-follow and running prepareRequestOptions
288
+ * on each hop, the agent gets re-picked to match the new URL's protocol.
289
+ *
290
+ * Caller can opt out by setting `followRedirects: false` on options
291
+ * (matches simple-get's contract) — in that case we hand the 3xx straight
292
+ * back without following.
293
+ *
294
+ * @private
295
+ * @param {Object} pOptions - Already passed through preRequest on the first call; on recursion, already passed through prepareRequestOptions.
296
+ * @param {(err?: Error, res?: import('http').IncomingMessage) => void} fCallback
297
+ */
298
+ _executeWithRedirects(pOptions, fCallback)
299
+ {
300
+ if (pOptions.followRedirects === false)
301
+ {
302
+ return libSimpleGet(pOptions, fCallback);
303
+ }
304
+
305
+ // Disable simple-get's own redirect loop — we own it from here.
306
+ pOptions.followRedirects = false;
307
+ const tmpOriginalURL = pOptions.url;
308
+ const tmpOriginalHost = this._parseHostname(tmpOriginalURL);
309
+
310
+ return libSimpleGet(pOptions, (pError, pResponse) =>
311
+ {
312
+ if (pError)
313
+ {
314
+ return fCallback(pError, pResponse);
315
+ }
316
+
317
+ if (pResponse.statusCode < 300 || pResponse.statusCode >= 400 || !pResponse.headers.location)
318
+ {
319
+ return fCallback(null, pResponse);
320
+ }
321
+
322
+ // 3xx with Location — drain and follow.
323
+ pResponse.resume();
324
+
325
+ let tmpRedirectsRemaining = (typeof pOptions.maxRedirects === 'number') ? pOptions.maxRedirects : 10;
326
+ if (tmpRedirectsRemaining <= 0)
327
+ {
328
+ return fCallback(new Error('too many redirects'));
329
+ }
330
+
331
+ const tmpNextOptions = this._buildRedirectedOptions(pOptions, pResponse, tmpOriginalURL, tmpOriginalHost);
332
+ tmpNextOptions.maxRedirects = tmpRedirectsRemaining - 1;
333
+
334
+ // Re-run the agent picker so the next hop's agent matches the
335
+ // (possibly different) protocol of the redirect target. We do
336
+ // NOT re-run preRequest in full — RestClientURLPrefix and
337
+ // prepareCookies are first-call-only.
338
+ const tmpPreparedNext = this.prepareRequestOptions(tmpNextOptions);
339
+
340
+ if (this.TraceLog)
341
+ {
342
+ this.fable.log.debug(`--> redirect ${pResponse.statusCode} to ${tmpPreparedNext.url}`);
343
+ }
344
+ return this._executeWithRedirects(tmpPreparedNext, fCallback);
345
+ });
346
+ }
347
+
109
348
  executeChunkedRequest(pOptions, fCallback)
110
349
  {
111
350
  let tmpOptions = this.preRequest(pOptions);
@@ -117,7 +356,7 @@ class FableServiceRestClient extends libFableServiceBase
117
356
  this.fable.log.debug(`Beginning ${tmpOptions.method} request to ${tmpOptions.url} at ${tmpOptions.RequestStartTime}`);
118
357
  }
119
358
 
120
- return libSimpleGet(tmpOptions,
359
+ return this._executeWithRedirects(tmpOptions,
121
360
  (pError, pResponse)=>
122
361
  {
123
362
  if (pError)
@@ -170,7 +409,7 @@ class FableServiceRestClient extends libFableServiceBase
170
409
  tmpOptions.json = false;
171
410
  tmpOptions.encoding = null;
172
411
 
173
- return libSimpleGet(tmpOptions,
412
+ return this._executeWithRedirects(tmpOptions,
174
413
  (pError, pResponse)=>
175
414
  {
176
415
  if (pError)
@@ -241,7 +480,7 @@ class FableServiceRestClient extends libFableServiceBase
241
480
  this.fable.log.debug(`Beginning ${tmpOptions.method} JSON request to ${tmpOptions.url} at ${tmpOptions.RequestStartTime}`);
242
481
  }
243
482
 
244
- return libSimpleGet(tmpOptions,
483
+ return this._executeWithRedirects(tmpOptions,
245
484
  (pError, pResponse)=>
246
485
  {
247
486
  if (pError)
@@ -419,7 +658,7 @@ class FableServiceRestClient extends libFableServiceBase
419
658
 
420
659
  tmpOptions.json = false;
421
660
 
422
- return libSimpleGet(tmpOptions,
661
+ return this._executeWithRedirects(tmpOptions,
423
662
  (pError, pResponse) =>
424
663
  {
425
664
  if (pError)
@@ -148,73 +148,84 @@ suite
148
148
 
149
149
  suite
150
150
  (
151
- 'KeepAlive Agent',
151
+ 'HTTP Agent',
152
152
  function ()
153
153
  {
154
154
  test
155
155
  (
156
- 'Initialize keep-alive agents via options.',
156
+ 'Always installs http/https agents, even when KeepAlive is not set.',
157
157
  function ()
158
158
  {
159
+ // Without an explicit agent, requests would fall through to
160
+ // http.globalAgent and hit the Node 20+ ~5s socket timeout.
159
161
  let testFable = new libFable();
160
- let tmpRestClient = testFable.instantiateServiceProvider('RestClient', { KeepAlive: true }, 'RestClient-KeepAlive-Options');
162
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-DefaultAgent');
161
163
 
162
164
  Expect(tmpRestClient.httpAgent).to.be.an('object');
163
165
  Expect(tmpRestClient.httpsAgent).to.be.an('object');
166
+ Expect(Boolean(tmpRestClient.httpAgent.keepAlive)).to.equal(false);
167
+ Expect(Boolean(tmpRestClient.httpsAgent.keepAlive)).to.equal(false);
168
+ }
169
+ );
170
+ test
171
+ (
172
+ 'Enables keepAlive on agents when KeepAlive option is set.',
173
+ function ()
174
+ {
175
+ let testFable = new libFable();
176
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', { KeepAlive: true }, 'RestClient-KeepAlive-Options');
177
+
164
178
  Expect(tmpRestClient.httpAgent.keepAlive).to.equal(true);
165
179
  Expect(tmpRestClient.httpsAgent.keepAlive).to.equal(true);
166
180
  }
167
181
  );
168
182
  test
169
183
  (
170
- 'Initialize keep-alive agents via fable settings.',
184
+ 'Enables keepAlive on agents via fable settings.',
171
185
  function ()
172
186
  {
173
187
  let testFable = new libFable({ RestClientKeepAlive: true });
174
188
  let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-KeepAlive-Settings');
175
189
 
176
- Expect(tmpRestClient.httpAgent).to.be.an('object');
177
- Expect(tmpRestClient.httpsAgent).to.be.an('object');
178
190
  Expect(tmpRestClient.httpAgent.keepAlive).to.equal(true);
179
191
  Expect(tmpRestClient.httpsAgent.keepAlive).to.equal(true);
180
192
  }
181
193
  );
182
194
  test
183
195
  (
184
- 'Pass additional agent options via KeepAliveAgentOptions.',
196
+ 'Passes additional agent options through KeepAliveAgentOptions.',
185
197
  function ()
186
198
  {
187
199
  let testFable = new libFable();
188
200
  let tmpRestClient = testFable.instantiateServiceProvider('RestClient',
189
201
  {
190
202
  KeepAlive: true,
191
- KeepAliveAgentOptions: { timeout: 300000 }
203
+ KeepAliveAgentOptions: { timeout: 300000, maxSockets: 32 }
192
204
  }, 'RestClient-KeepAlive-AgentOpts');
193
205
 
194
- Expect(tmpRestClient.httpAgent).to.be.an('object');
195
- Expect(tmpRestClient.httpsAgent).to.be.an('object');
196
206
  Expect(tmpRestClient.httpAgent.keepAlive).to.equal(true);
197
- Expect(tmpRestClient.httpsAgent.keepAlive).to.equal(true);
198
- // Verify the custom timeout was passed through
199
207
  Expect(tmpRestClient.httpAgent.options.timeout).to.equal(300000);
208
+ Expect(tmpRestClient.httpAgent.options.maxSockets).to.equal(32);
200
209
  Expect(tmpRestClient.httpsAgent.options.timeout).to.equal(300000);
201
210
  }
202
211
  );
203
- test
212
+ test
204
213
  (
205
- 'Do not create agents when KeepAlive is not set.',
214
+ 'KeepAliveAgentOptions apply even when KeepAlive is not enabled.',
206
215
  function ()
207
216
  {
217
+ // Tuning options still flow through when keepAlive is off.
208
218
  let testFable = new libFable();
209
- let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-NoKeepAlive');
219
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient',
220
+ { KeepAliveAgentOptions: { maxSockets: 8 } }, 'RestClient-AgentOpts-NoKeepAlive');
210
221
 
211
- Expect(tmpRestClient.httpAgent).to.equal(undefined);
212
- Expect(tmpRestClient.httpsAgent).to.equal(undefined);
222
+ Expect(Boolean(tmpRestClient.httpAgent.keepAlive)).to.equal(false);
223
+ Expect(tmpRestClient.httpAgent.options.maxSockets).to.equal(8);
213
224
  }
214
225
  );
215
226
  test
216
227
  (
217
- 'Inject HTTP agent into request options for http URLs.',
228
+ 'Injects the http agent on http:// URLs.',
218
229
  function (fTestComplete)
219
230
  {
220
231
  var tmpServer = libHTTP.createServer(function (pReq, pRes)
@@ -227,9 +238,8 @@ suite
227
238
  {
228
239
  var tmpPort = tmpServer.address().port;
229
240
  var testFable = new libFable();
230
- var tmpRestClient = testFable.instantiateServiceProvider('RestClient', { KeepAlive: true }, 'RestClient-KeepAlive-HTTP');
241
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-AgentInject-HTTP');
231
242
 
232
- // Wrap prepareRequestOptions to capture the final options
233
243
  var tmpOriginalPrepare = tmpRestClient.prepareRequestOptions;
234
244
  var tmpCapturedAgent = null;
235
245
  tmpRestClient.prepareRequestOptions = function (pOptions)
@@ -243,7 +253,6 @@ suite
243
253
  function (pError, pResponse, pBody)
244
254
  {
245
255
  Expect(tmpCapturedAgent).to.equal(tmpRestClient.httpAgent);
246
- Expect(pBody).to.be.an('object');
247
256
  Expect(pBody.OK).to.equal(true);
248
257
  tmpServer.close();
249
258
  fTestComplete();
@@ -253,7 +262,92 @@ suite
253
262
  );
254
263
  test
255
264
  (
256
- 'Chain with previously set prepareRequestOptions.',
265
+ 'Selects the https agent for https:// URLs.',
266
+ function ()
267
+ {
268
+ // We don't need a live HTTPS server to verify the selection
269
+ // logic — invoke prepareRequestOptions directly and inspect
270
+ // which agent was chosen.
271
+ let testFable = new libFable();
272
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-AgentInject-HTTPS');
273
+
274
+ let tmpHttps = tmpRestClient.prepareRequestOptions({ url: 'https://example.com/x' });
275
+ let tmpHttp = tmpRestClient.prepareRequestOptions({ url: 'http://example.com/x' });
276
+
277
+ Expect(tmpHttps.agent).to.equal(tmpRestClient.httpsAgent);
278
+ Expect(tmpHttp.agent).to.equal(tmpRestClient.httpAgent);
279
+ }
280
+ );
281
+ test
282
+ (
283
+ 'Selects the http agent for relative URLs and option objects without a URL.',
284
+ function ()
285
+ {
286
+ // simple-get's protocol decision is `opts.protocol === 'https:'
287
+ // ? https : http`, so a URL with no protocol — including a
288
+ // relative path or a pre-parsed options object — routes through
289
+ // the http module. The agent we stamp on must match, otherwise
290
+ // Node throws ERR_INVALID_PROTOCOL on dispatch.
291
+ let testFable = new libFable();
292
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-AgentInject-Relative');
293
+
294
+ let tmpRelative = tmpRestClient.prepareRequestOptions({ url: '/1.0/Users/Count' });
295
+ let tmpNoUrl = tmpRestClient.prepareRequestOptions({ hostname: 'localhost', port: 8086, path: '/1.0/Users' });
296
+
297
+ Expect(tmpRelative.agent).to.equal(tmpRestClient.httpAgent);
298
+ Expect(tmpNoUrl.agent).to.equal(tmpRestClient.httpAgent);
299
+ }
300
+ );
301
+ test
302
+ (
303
+ 'A relative URL is dispatched without throwing ERR_INVALID_PROTOCOL.',
304
+ function (fTestComplete)
305
+ {
306
+ // End-to-end regression: before the fix, fable stamped the
307
+ // httpsAgent on relative URLs while simple-get routed them
308
+ // through http.request, which made Node throw synchronously.
309
+ // We point fable at a real http server via RestClientURLPrefix
310
+ // so the relative URL becomes resolvable, and assert that the
311
+ // request completes (rather than throwing on dispatch).
312
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
313
+ {
314
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
315
+ pRes.end(JSON.stringify({ Path: pReq.url }));
316
+ });
317
+
318
+ tmpServer.listen(0, function ()
319
+ {
320
+ var tmpPort = tmpServer.address().port;
321
+ var testFable = new libFable({ RestClientURLPrefix: 'http://localhost:' + tmpPort });
322
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-RelativeURL-Dispatch');
323
+
324
+ var tmpOriginalPrepare = tmpRestClient.prepareRequestOptions;
325
+ var tmpCapturedAgent = null;
326
+ tmpRestClient.prepareRequestOptions = function (pOptions)
327
+ {
328
+ let tmpResult = tmpOriginalPrepare(pOptions);
329
+ if (tmpCapturedAgent === null)
330
+ {
331
+ tmpCapturedAgent = tmpResult.agent;
332
+ }
333
+ return tmpResult;
334
+ };
335
+
336
+ tmpRestClient.getJSON('/relative/path',
337
+ function (pError, pResponse, pBody)
338
+ {
339
+ Expect(pError).to.equal(null);
340
+ Expect(tmpCapturedAgent).to.equal(tmpRestClient.httpAgent);
341
+ Expect(pBody.Path).to.equal('/relative/path');
342
+ tmpServer.close();
343
+ fTestComplete();
344
+ });
345
+ });
346
+ }
347
+ );
348
+ test
349
+ (
350
+ 'Chains with previously set prepareRequestOptions.',
257
351
  function (fTestComplete)
258
352
  {
259
353
  var tmpServer = libHTTP.createServer(function (pReq, pRes)
@@ -268,13 +362,10 @@ suite
268
362
  var testFable = new libFable();
269
363
  var tmpRestClient = testFable.instantiateServiceProvider('RestClient', { KeepAlive: true }, 'RestClient-KeepAlive-Chain');
270
364
 
271
- // Set a custom prepareRequestOptions AFTER keep-alive init to verify chaining
272
- var tmpKeepAlivePrepare = tmpRestClient.prepareRequestOptions;
365
+ var tmpInstalledPrepare = tmpRestClient.prepareRequestOptions;
273
366
  tmpRestClient.prepareRequestOptions = function (pOptions)
274
367
  {
275
- // Apply keep-alive first
276
- let tmpResult = tmpKeepAlivePrepare(pOptions);
277
- // Then add custom header
368
+ let tmpResult = tmpInstalledPrepare(pOptions);
278
369
  if (!tmpResult.headers)
279
370
  {
280
371
  tmpResult.headers = {};
@@ -286,7 +377,6 @@ suite
286
377
  tmpRestClient.getJSON('http://localhost:' + tmpPort + '/test',
287
378
  function (pError, pResponse, pBody)
288
379
  {
289
- Expect(pBody).to.be.an('object');
290
380
  Expect(pBody.CustomHeader).to.equal('test-value');
291
381
  tmpServer.close();
292
382
  fTestComplete();
@@ -294,6 +384,440 @@ suite
294
384
  });
295
385
  }
296
386
  );
387
+ test
388
+ (
389
+ 'initializeKeepAliveAgent remains a back-compat entry point.',
390
+ function ()
391
+ {
392
+ let testFable = new libFable();
393
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-BackCompat');
394
+
395
+ // Default constructor: no keepAlive
396
+ Expect(Boolean(tmpRestClient.httpAgent.keepAlive)).to.equal(false);
397
+
398
+ // Calling the legacy method flips keepAlive on
399
+ tmpRestClient.initializeKeepAliveAgent({ maxSockets: 4 });
400
+ Expect(tmpRestClient.httpAgent.keepAlive).to.equal(true);
401
+ Expect(tmpRestClient.httpAgent.options.maxSockets).to.equal(4);
402
+ }
403
+ );
404
+ }
405
+ );
406
+
407
+ suite
408
+ (
409
+ 'Request Timeout',
410
+ function ()
411
+ {
412
+ // Helper: spin up a local HTTP server that never responds so we can
413
+ // verify that the simple-get 'Request timed out' path fires on our
414
+ // configured timeout rather than any ambient http.globalAgent default.
415
+ var createHangingServer = function (fOnListen)
416
+ {
417
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
418
+ {
419
+ // Intentionally never write or end the response
420
+ });
421
+ tmpServer.listen(0, function ()
422
+ {
423
+ fOnListen(tmpServer, tmpServer.address().port);
424
+ });
425
+ return tmpServer;
426
+ };
427
+
428
+ test
429
+ (
430
+ 'Applies the library default timeout (60000ms) when none is supplied.',
431
+ function ()
432
+ {
433
+ let testFable = new libFable();
434
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-DefaultTimeout');
435
+
436
+ Expect(tmpRestClient.defaultRequestTimeout).to.equal(60000);
437
+
438
+ let tmpPrepared = tmpRestClient.preRequest({ url: 'http://example.com/x', method: 'GET' });
439
+ Expect(tmpPrepared.timeout).to.equal(60000);
440
+ }
441
+ );
442
+ test
443
+ (
444
+ 'RequestTimeout constructor option overrides the library default.',
445
+ function ()
446
+ {
447
+ let testFable = new libFable();
448
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', { RequestTimeout: 60000 }, 'RestClient-OptTimeout');
449
+
450
+ Expect(tmpRestClient.defaultRequestTimeout).to.equal(60000);
451
+
452
+ let tmpPrepared = tmpRestClient.preRequest({ url: 'http://example.com/x', method: 'GET' });
453
+ Expect(tmpPrepared.timeout).to.equal(60000);
454
+ }
455
+ );
456
+ test
457
+ (
458
+ 'RestClientRequestTimeout fable setting overrides the library default.',
459
+ function ()
460
+ {
461
+ let testFable = new libFable({ RestClientRequestTimeout: 45000 });
462
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-SettingTimeout');
463
+
464
+ Expect(tmpRestClient.defaultRequestTimeout).to.equal(45000);
465
+ }
466
+ );
467
+ test
468
+ (
469
+ 'Constructor option takes precedence over fable setting.',
470
+ function ()
471
+ {
472
+ let testFable = new libFable({ RestClientRequestTimeout: 45000 });
473
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', { RequestTimeout: 15000 }, 'RestClient-TimeoutPrecedence');
474
+
475
+ Expect(tmpRestClient.defaultRequestTimeout).to.equal(15000);
476
+ }
477
+ );
478
+ test
479
+ (
480
+ 'Caller-supplied timeout on the request options is preserved.',
481
+ function ()
482
+ {
483
+ let testFable = new libFable();
484
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-CallerTimeout');
485
+
486
+ let tmpPrepared = tmpRestClient.preRequest({ url: 'http://example.com/x', method: 'GET', timeout: 1234 });
487
+ Expect(tmpPrepared.timeout).to.equal(1234);
488
+ }
489
+ );
490
+ test
491
+ (
492
+ 'Explicit timeout of 0 on the request options is preserved.',
493
+ function ()
494
+ {
495
+ // 0 is a valid numeric value — it signals "override the mystery
496
+ // default with no timeout" and must not be replaced.
497
+ let testFable = new libFable();
498
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-ZeroTimeout');
499
+
500
+ let tmpPrepared = tmpRestClient.preRequest({ url: 'http://example.com/x', method: 'GET', timeout: 0 });
501
+ Expect(tmpPrepared.timeout).to.equal(0);
502
+ }
503
+ );
504
+ test
505
+ (
506
+ 'RequestTimeout value of 0 is honored.',
507
+ function ()
508
+ {
509
+ let testFable = new libFable();
510
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', { RequestTimeout: 0 }, 'RestClient-ZeroOptTimeout');
511
+
512
+ Expect(tmpRestClient.defaultRequestTimeout).to.equal(0);
513
+
514
+ let tmpPrepared = tmpRestClient.preRequest({ url: 'http://example.com/x', method: 'GET' });
515
+ Expect(tmpPrepared.timeout).to.equal(0);
516
+ }
517
+ );
518
+ test
519
+ (
520
+ 'A short RequestTimeout actually aborts a hanging request.',
521
+ function (fTestComplete)
522
+ {
523
+ this.timeout(5000);
524
+ var tmpServer;
525
+ tmpServer = createHangingServer(function (pServer, pPort)
526
+ {
527
+ var testFable = new libFable();
528
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', { RequestTimeout: 300 }, 'RestClient-LiveTimeout');
529
+
530
+ var tmpStart = Date.now();
531
+ tmpRestClient.getJSON('http://localhost:' + pPort + '/hangs',
532
+ function (pError, pResponse, pBody)
533
+ {
534
+ var tmpElapsed = Date.now() - tmpStart;
535
+ Expect(pError).to.be.an.instanceof(Error);
536
+ Expect(pError.message).to.contain('timed out');
537
+ // Guard against the Node 20+ ~5s globalAgent timeout
538
+ // silently beating our 300ms setting.
539
+ Expect(tmpElapsed).to.be.lessThan(2500);
540
+ pServer.close();
541
+ fTestComplete();
542
+ });
543
+ });
544
+ }
545
+ );
546
+ }
547
+ );
548
+
549
+ suite
550
+ (
551
+ 'Redirect Following',
552
+ function ()
553
+ {
554
+ test
555
+ (
556
+ 'Follows a basic 301 redirect end-to-end.',
557
+ function (fTestComplete)
558
+ {
559
+ // Single server: /start returns 301 to /dest, /dest returns 200 + JSON.
560
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
561
+ {
562
+ if (pReq.url === '/start')
563
+ {
564
+ pRes.writeHead(301, { Location: '/dest' });
565
+ pRes.end();
566
+ return;
567
+ }
568
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
569
+ pRes.end(JSON.stringify({ Reached: pReq.url }));
570
+ });
571
+ tmpServer.listen(0, function ()
572
+ {
573
+ var tmpPort = tmpServer.address().port;
574
+ var testFable = new libFable();
575
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-Basic');
576
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/start',
577
+ function (pError, pResponse, pBody)
578
+ {
579
+ Expect(pError).to.equal(null);
580
+ Expect(pBody.Reached).to.equal('/dest');
581
+ tmpServer.close();
582
+ fTestComplete();
583
+ });
584
+ });
585
+ }
586
+ );
587
+ test
588
+ (
589
+ 'Re-picks the agent on a protocol-changing redirect.',
590
+ function (fTestComplete)
591
+ {
592
+ // http server redirects to an https URL. We don't need the https
593
+ // destination to be reachable — we just need to verify that
594
+ // prepareRequestOptions runs again on the redirect target and
595
+ // stamps the httpsAgent on the second hop.
596
+ var tmpAssignedAgents = [];
597
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
598
+ {
599
+ pRes.writeHead(302, { Location: 'https://nowhere.invalid/x' });
600
+ pRes.end();
601
+ });
602
+ tmpServer.listen(0, function ()
603
+ {
604
+ var tmpPort = tmpServer.address().port;
605
+ var testFable = new libFable();
606
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-ProtoSwitch');
607
+
608
+ var tmpOriginalPrepare = tmpRestClient.prepareRequestOptions;
609
+ tmpRestClient.prepareRequestOptions = function (pOptions)
610
+ {
611
+ var tmpResult = tmpOriginalPrepare(pOptions);
612
+ tmpAssignedAgents.push({ url: tmpResult.url, agent: tmpResult.agent });
613
+ return tmpResult;
614
+ };
615
+
616
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/start',
617
+ function (pError)
618
+ {
619
+ // The second hop will fail (DNS lookup of nowhere.invalid)
620
+ // but the agent re-pick must have already happened
621
+ // before that — and crucially, must NOT have thrown
622
+ // ERR_INVALID_PROTOCOL synchronously like the old code.
623
+ Expect(tmpAssignedAgents.length).to.be.at.least(2);
624
+ Expect(tmpAssignedAgents[0].agent).to.equal(tmpRestClient.httpAgent);
625
+ Expect(tmpAssignedAgents[1].agent).to.equal(tmpRestClient.httpsAgent);
626
+ Expect(tmpAssignedAgents[1].url).to.equal('https://nowhere.invalid/x');
627
+ tmpServer.close();
628
+ fTestComplete();
629
+ });
630
+ });
631
+ }
632
+ );
633
+ test
634
+ (
635
+ 'Bails with "too many redirects" when the budget is exceeded.',
636
+ function (fTestComplete)
637
+ {
638
+ // Self-redirecting server. Cap at 3 hops so the test stays quick.
639
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
640
+ {
641
+ pRes.writeHead(302, { Location: pReq.url });
642
+ pRes.end();
643
+ });
644
+ tmpServer.listen(0, function ()
645
+ {
646
+ var tmpPort = tmpServer.address().port;
647
+ var testFable = new libFable();
648
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-Loop');
649
+ tmpRestClient.getJSON({ url: 'http://localhost:' + tmpPort + '/loop', maxRedirects: 3 },
650
+ function (pError)
651
+ {
652
+ Expect(pError).to.be.an.instanceof(Error);
653
+ Expect(pError.message).to.equal('too many redirects');
654
+ tmpServer.close();
655
+ fTestComplete();
656
+ });
657
+ });
658
+ }
659
+ );
660
+ test
661
+ (
662
+ 'Drops cookie and authorization on cross-origin redirect.',
663
+ function (fTestComplete)
664
+ {
665
+ // Two servers on different ports. We use 127.0.0.1 vs localhost
666
+ // to make them count as different hostnames per
667
+ // _parseHostname's URL-string check.
668
+ var tmpReceivedAtA = null;
669
+ var tmpReceivedAtB = null;
670
+ var tmpServerB = libHTTP.createServer(function (pReq, pRes)
671
+ {
672
+ tmpReceivedAtB = pReq.headers;
673
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
674
+ pRes.end(JSON.stringify({ ok: true }));
675
+ });
676
+ tmpServerB.listen(0, function ()
677
+ {
678
+ var tmpPortB = tmpServerB.address().port;
679
+ var tmpServerA = libHTTP.createServer(function (pReq, pRes)
680
+ {
681
+ tmpReceivedAtA = pReq.headers;
682
+ pRes.writeHead(302, { Location: 'http://127.0.0.1:' + tmpPortB + '/dest' });
683
+ pRes.end();
684
+ });
685
+ tmpServerA.listen(0, function ()
686
+ {
687
+ var tmpPortA = tmpServerA.address().port;
688
+ var testFable = new libFable();
689
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-CrossOrigin');
690
+ tmpRestClient.getJSON({
691
+ url: 'http://localhost:' + tmpPortA + '/start',
692
+ headers: { cookie: 'session=abc', authorization: 'Bearer xyz' }
693
+ },
694
+ function (pError, pResponse, pBody)
695
+ {
696
+ Expect(pError).to.equal(null);
697
+ Expect(pBody.ok).to.equal(true);
698
+ Expect(tmpReceivedAtA.cookie).to.equal('session=abc');
699
+ Expect(tmpReceivedAtA.authorization).to.equal('Bearer xyz');
700
+ Expect(tmpReceivedAtB.cookie).to.equal(undefined);
701
+ Expect(tmpReceivedAtB.authorization).to.equal(undefined);
702
+ tmpServerA.close();
703
+ tmpServerB.close();
704
+ fTestComplete();
705
+ });
706
+ });
707
+ });
708
+ }
709
+ );
710
+ test
711
+ (
712
+ 'Converts POST to GET on a 301/302 redirect and drops the body.',
713
+ function (fTestComplete)
714
+ {
715
+ var tmpDestRequest = null;
716
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
717
+ {
718
+ if (pReq.url === '/start')
719
+ {
720
+ pRes.writeHead(301, { Location: '/dest' });
721
+ pRes.end();
722
+ return;
723
+ }
724
+ tmpDestRequest = { method: pReq.method, headers: pReq.headers };
725
+ var tmpBody = '';
726
+ pReq.on('data', function (pChunk) { tmpBody += pChunk; });
727
+ pReq.on('end', function ()
728
+ {
729
+ tmpDestRequest.body = tmpBody;
730
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
731
+ pRes.end(JSON.stringify({ ok: true }));
732
+ });
733
+ });
734
+ tmpServer.listen(0, function ()
735
+ {
736
+ var tmpPort = tmpServer.address().port;
737
+ var testFable = new libFable();
738
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-PostToGet');
739
+ tmpRestClient.postJSON({ url: 'http://localhost:' + tmpPort + '/start', body: { foo: 'bar' } },
740
+ function (pError, pResponse, pBody)
741
+ {
742
+ Expect(pError).to.equal(null);
743
+ Expect(pBody.ok).to.equal(true);
744
+ Expect(tmpDestRequest.method).to.equal('GET');
745
+ Expect(tmpDestRequest.body).to.equal('');
746
+ Expect(tmpDestRequest.headers['content-type']).to.equal(undefined);
747
+ Expect(tmpDestRequest.headers['content-length']).to.equal(undefined);
748
+ tmpServer.close();
749
+ fTestComplete();
750
+ });
751
+ });
752
+ }
753
+ );
754
+ test
755
+ (
756
+ 'Resolves a relative Location header against the current URL.',
757
+ function (fTestComplete)
758
+ {
759
+ // Server returns a relative Location ("dest" instead of "/dest").
760
+ // RFC 7231 allows this; the next hop's URL must be resolved
761
+ // against the URL that produced the redirect.
762
+ var tmpDestRequestURL = null;
763
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
764
+ {
765
+ if (pReq.url === '/section/start')
766
+ {
767
+ pRes.writeHead(302, { Location: 'dest' });
768
+ pRes.end();
769
+ return;
770
+ }
771
+ tmpDestRequestURL = pReq.url;
772
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
773
+ pRes.end(JSON.stringify({ ok: true }));
774
+ });
775
+ tmpServer.listen(0, function ()
776
+ {
777
+ var tmpPort = tmpServer.address().port;
778
+ var testFable = new libFable();
779
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-RelativeLocation');
780
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/section/start',
781
+ function (pError, pResponse, pBody)
782
+ {
783
+ Expect(pError).to.equal(null);
784
+ Expect(pBody.ok).to.equal(true);
785
+ Expect(tmpDestRequestURL).to.equal('/section/dest');
786
+ tmpServer.close();
787
+ fTestComplete();
788
+ });
789
+ });
790
+ }
791
+ );
792
+ test
793
+ (
794
+ 'Honors followRedirects: false by handing the 3xx straight back.',
795
+ function (fTestComplete)
796
+ {
797
+ // When the caller opts out, we shouldn't follow the redirect —
798
+ // the JSON parser will fail because the 3xx body is empty,
799
+ // which is the expected pre-fix behavior.
800
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
801
+ {
802
+ pRes.writeHead(302, { Location: '/dest' });
803
+ pRes.end();
804
+ });
805
+ tmpServer.listen(0, function ()
806
+ {
807
+ var tmpPort = tmpServer.address().port;
808
+ var testFable = new libFable();
809
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-OptOut');
810
+ tmpRestClient.getJSON({ url: 'http://localhost:' + tmpPort + '/start', followRedirects: false },
811
+ function (pError, pResponse)
812
+ {
813
+ Expect(pResponse.statusCode).to.equal(302);
814
+ Expect(pResponse.headers.location).to.equal('/dest');
815
+ tmpServer.close();
816
+ fTestComplete();
817
+ });
818
+ });
819
+ }
820
+ );
297
821
  }
298
822
  );
299
823