nucleus-core-ts 0.9.966 → 0.9.967
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/dist/.build-ok +1 -1
- package/dist/src/Client/Proxy/httpProxy.js +74 -4
- package/package.json +1 -1
package/dist/.build-ok
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.967
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
|
|
2
|
-
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
2
|
import { canBufferForRetry, timeoutForBody } from './largeBody';
|
|
3
|
+
import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
|
|
4
4
|
async function fetchSuppressedClaims(url, cookieHeader, logger) {
|
|
5
5
|
const empty = {
|
|
6
6
|
suppressedClaims: [],
|
|
@@ -368,9 +368,58 @@ export function createHttpProxyHandler(config) {
|
|
|
368
368
|
function injectToken(headers, token, headerName) {
|
|
369
369
|
headers.set(headerName, `Bearer ${token}`);
|
|
370
370
|
}
|
|
371
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Forward one request upstream, bounded by BOTH clocks that can end it.
|
|
373
|
+
*
|
|
374
|
+
* The timeout below only bounds time-to-first-headers: it is cleared the
|
|
375
|
+
* moment headers arrive, because a streamed body (SSE, a long download, a
|
|
376
|
+
* model's token stream) legitimately takes as long as it takes. That is
|
|
377
|
+
* correct — but on its own it left a streamed request with NO way to end.
|
|
378
|
+
*
|
|
379
|
+
* So when the person closed the tab, the browser's connection went away and
|
|
380
|
+
* ours did not. The upstream request stayed open, holding one of the
|
|
381
|
+
* runtime's outgoing connection slots, forever.
|
|
382
|
+
*
|
|
383
|
+
* That is not a slow leak. Measured on Bun 1.3.14: 256 clients that connect
|
|
384
|
+
* and hang up leave 256 upstream requests open (0 cancelled), and the process
|
|
385
|
+
* can then make no outgoing request at all — every later fetch times out.
|
|
386
|
+
* A single tab reconnecting an EventSource 256 times does it alone.
|
|
387
|
+
*
|
|
388
|
+
* Cancelling the body we hand back (`body.cancel()`) does NOT release the
|
|
389
|
+
* slot; only aborting the upstream fetch does. Measured both ways.
|
|
390
|
+
*
|
|
391
|
+
* Hence `clientSignal`: the client's hangup is wired to the same controller
|
|
392
|
+
* the timeout uses, so whichever ends first ends the upstream request. The
|
|
393
|
+
* listener is left attached deliberately — it belongs to this one request and
|
|
394
|
+
* is collected with it, and aborting an already-finished fetch is a no-op.
|
|
395
|
+
*/ async function forwardRequest(finalUrl, method, headers, body, timeout, followRedirects, clientSignal) {
|
|
372
396
|
const controller = new AbortController();
|
|
373
397
|
const timeoutId = setTimeout(()=>controller.abort(), timeout);
|
|
398
|
+
// Tracked so the catch below can tell "the user left" from "upstream was
|
|
399
|
+
// too slow". Both surface as AbortError; only one is a fault worth a 504.
|
|
400
|
+
let clientGone = false;
|
|
401
|
+
if (clientSignal) {
|
|
402
|
+
if (clientSignal.aborted) {
|
|
403
|
+
clientGone = true;
|
|
404
|
+
controller.abort();
|
|
405
|
+
} else {
|
|
406
|
+
clientSignal.addEventListener('abort', ()=>{
|
|
407
|
+
clientGone = true;
|
|
408
|
+
clearTimeout(timeoutId);
|
|
409
|
+
controller.abort();
|
|
410
|
+
/*
|
|
411
|
+
* Logged HERE, not in the catch below, because the common case
|
|
412
|
+
* never reaches the catch: once headers have arrived this function
|
|
413
|
+
* has already returned, and the hangup happens while the body is
|
|
414
|
+
* still streaming. That is exactly the case that used to leak, so
|
|
415
|
+
* it is exactly the case that has to be visible — a cancellation
|
|
416
|
+
* nobody can see is how this went unnoticed for months.
|
|
417
|
+
*/ logger.info(`Client disconnected, upstream cancelled: ${finalUrl}`);
|
|
418
|
+
}, {
|
|
419
|
+
once: true
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
374
423
|
try {
|
|
375
424
|
const response = await fetch(finalUrl, {
|
|
376
425
|
method,
|
|
@@ -383,6 +432,13 @@ export function createHttpProxyHandler(config) {
|
|
|
383
432
|
return response;
|
|
384
433
|
} catch (error) {
|
|
385
434
|
clearTimeout(timeoutId);
|
|
435
|
+
if (clientGone && error instanceof Error && error.name === 'AbortError') {
|
|
436
|
+
// Re-labelled so an operator reading the log is not sent hunting for a
|
|
437
|
+
// slow upstream that was never slow.
|
|
438
|
+
const gone = new Error('Client disconnected before the response arrived');
|
|
439
|
+
gone.name = 'ClientGoneError';
|
|
440
|
+
throw gone;
|
|
441
|
+
}
|
|
386
442
|
throw error;
|
|
387
443
|
}
|
|
388
444
|
}
|
|
@@ -497,7 +553,7 @@ export function createHttpProxyHandler(config) {
|
|
|
497
553
|
}
|
|
498
554
|
}
|
|
499
555
|
try {
|
|
500
|
-
const response = await forwardRequest(finalUrl, req.method, headers, bodyToSend, timeout, followRedirects);
|
|
556
|
+
const response = await forwardRequest(finalUrl, req.method, headers, bodyToSend, timeout, followRedirects, req.signal);
|
|
501
557
|
// --- REACTIVE REFRESH: API returned 401, try refresh + retry ---
|
|
502
558
|
if (response.status === 401 && refreshEnabled && (refreshConfig.retryOn401 ?? true) && refreshToken && refreshSetCookies.length === 0) {
|
|
503
559
|
logger.info(`[TokenRefresh] Got 401, attempting reactive refresh for ${path}`);
|
|
@@ -513,7 +569,7 @@ export function createHttpProxyHandler(config) {
|
|
|
513
569
|
const retryHeaders = buildHeaders(req, target, clientIp);
|
|
514
570
|
injectToken(retryHeaders, refreshResult.accessToken, tokenHeaderName);
|
|
515
571
|
logger.info(`[TokenRefresh] Retrying ${req.method} ${path} with new token`);
|
|
516
|
-
const retryResponse = await forwardRequest(finalUrl, req.method, retryHeaders, bodyForRetry ? new Response(bodyForRetry).body : null, timeout, followRedirects);
|
|
572
|
+
const retryResponse = await forwardRequest(finalUrl, req.method, retryHeaders, bodyForRetry ? new Response(bodyForRetry).body : null, timeout, followRedirects, req.signal);
|
|
517
573
|
const retryDuration = performance.now() - startTime;
|
|
518
574
|
logger.info(`Response (after refresh): ${retryResponse.status} (${retryDuration.toFixed(0)}ms)`);
|
|
519
575
|
config.onResponse?.(path, retryResponse.status, retryDuration);
|
|
@@ -560,6 +616,20 @@ export function createHttpProxyHandler(config) {
|
|
|
560
616
|
} catch (error) {
|
|
561
617
|
const duration = performance.now() - startTime;
|
|
562
618
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
619
|
+
/*
|
|
620
|
+
* The person closed the tab. Nothing failed, and there is nobody left
|
|
621
|
+
* to send a status to — so this is not an error, and calling it one
|
|
622
|
+
* sends whoever reads the log hunting a timeout that never happened.
|
|
623
|
+
*
|
|
624
|
+
* Reported with the elapsed time, because on a streaming path "the
|
|
625
|
+
* client left after 4 minutes" is genuinely useful and "Gateway
|
|
626
|
+
* Timeout" is genuinely misleading.
|
|
627
|
+
*/ if (err.name === 'ClientGoneError') {
|
|
628
|
+
logger.info(`Client disconnected after ${duration.toFixed(0)}ms, upstream cancelled: ${path}`);
|
|
629
|
+
return new Response(null, {
|
|
630
|
+
status: 499
|
|
631
|
+
});
|
|
632
|
+
}
|
|
563
633
|
if (isStreamingPath) {
|
|
564
634
|
logger.error(`[SSE:proxy] FETCH ERROR for ${path} after ${duration.toFixed(0)}ms: ${err.name} - ${err.message}`);
|
|
565
635
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.967",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|