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 CHANGED
@@ -1 +1 @@
1
- 0.9.966
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
- async function forwardRequest(finalUrl, method, headers, body, timeout, followRedirects) {
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.966",
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",