nucleus-core-ts 0.9.950 → 0.9.952

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.
@@ -18,6 +18,20 @@ export interface WsProxyTarget {
18
18
  * refresh cookie is present, exchange it for a fresh access token and inject THAT.
19
19
  */
20
20
  tokenRefresh?: TokenRefreshConfig;
21
+ /**
22
+ * Let a handshake through even when no credential could be produced.
23
+ *
24
+ * Default `false`, and that default is the fix: a target that declares
25
+ * `injectTokenFromCookie` is a target that needs a session, so the proxy now
26
+ * answers 401 BEFORE upgrading rather than accepting a socket the backend
27
+ * will refuse. Accepting first is what silently disarms the client's own
28
+ * "three refused handshakes and stop" guard — the browser sees `onopen`, the
29
+ * counter resets, and one logged-out tab reconnects forever.
30
+ *
31
+ * Set this to `true` only for a route that genuinely serves anonymous
32
+ * clients while still injecting a token when one happens to exist.
33
+ */
34
+ allowUnauthenticated?: boolean;
21
35
  headers?: Record<string, string>;
22
36
  changeOrigin?: boolean;
23
37
  secure?: boolean;
@@ -51,7 +51,15 @@ export function createWsProxyHandler(config) {
51
51
  }
52
52
  return null;
53
53
  }
54
- async function buildBackendUrl(path, target, cookies, query) {
54
+ /**
55
+ * The backend URL, plus whether this handshake carries a credential at all.
56
+ *
57
+ * The second half is the point. This function already KNEW when it had no
58
+ * token — it logged "No token found" and handed back a URL anyway, and the
59
+ * caller upgraded the socket regardless. That silence is what turns one
60
+ * logged-out browser tab into a permanent reconnect storm; see the refusal in
61
+ * `upgrade()` for the measurements.
62
+ */ async function buildBackendUrl(path, target, cookies, query) {
55
63
  let baseUrl = target.url.replace(/\/$/, '');
56
64
  if (baseUrl.startsWith('http')) {
57
65
  baseUrl = httpToWs(baseUrl);
@@ -65,6 +73,9 @@ export function createWsProxyHandler(config) {
65
73
  if (queryString) {
66
74
  url = `${url}?${queryString}`;
67
75
  }
76
+ // A target that declares `injectTokenFromCookie` is a target that needs a
77
+ // session. Until a credential is actually produced, this handshake has none.
78
+ let authenticated = !target.injectTokenFromCookie;
68
79
  if (target.injectTokenFromCookie) {
69
80
  const paramName = target.injectTokenFromCookie.queryParam ?? 'token';
70
81
  // PRECEDENCE: respect a token the client already supplied in the URL.
@@ -78,6 +89,7 @@ export function createWsProxyHandler(config) {
78
89
  // the cookie when the client supplied no token.
79
90
  if (query.get(paramName)) {
80
91
  logger.info(`[WS:auth] Using client-supplied ${paramName} for ${path}`);
92
+ authenticated = true;
81
93
  } else {
82
94
  let token = cookies[target.injectTokenFromCookie.cookieName];
83
95
  let tokenSource = target.injectTokenFromCookie.cookieName;
@@ -112,12 +124,16 @@ export function createWsProxyHandler(config) {
112
124
  if (token) {
113
125
  url = addQueryParam(url, paramName, token);
114
126
  logger.info(`[WS:auth] Token injected from ${tokenSource} for ${path}`);
127
+ authenticated = true;
115
128
  } else {
116
129
  logger.warn(`[WS:auth] No token found for ${path} — tried: ${target.injectTokenFromCookie.cookieName}${target.injectTokenFromCookie.fallbackCookieNames ? `, ${target.injectTokenFromCookie.fallbackCookieNames.join(', ')}` : ''}`);
117
130
  }
118
131
  }
119
132
  }
120
- return url;
133
+ return {
134
+ url,
135
+ authenticated
136
+ };
121
137
  }
122
138
  return {
123
139
  async upgrade (req, server) {
@@ -138,7 +154,33 @@ export function createWsProxyHandler(config) {
138
154
  return false;
139
155
  }
140
156
  const cookies = parseCookies(req.headers.get('cookie'));
141
- const backendUrl = await buildBackendUrl(path, target, cookies, url.searchParams);
157
+ const { url: backendUrl, authenticated } = await buildBackendUrl(path, target, cookies, url.searchParams);
158
+ // Refuse the upgrade instead of accepting a socket that cannot survive.
159
+ //
160
+ // This handler used to say yes first and dial the backend after. When the
161
+ // backend then refused the handshake, the proxy closed the client socket —
162
+ // but the browser had already seen `onopen`, so nucleus' own client guard
163
+ // (HANDSHAKE_FAILURE_LIMIT, three refused handshakes and stop) reset its
164
+ // counter every single attempt and could never trip. A guard on the far
165
+ // side of a proxy that says yes first is decoration.
166
+ //
167
+ // Measured on a live install, 14 Aug 2026: one logged-out tab produced 91
168
+ // refusals in 180 seconds — a metronome at exactly 2.0s, 46% of all log
169
+ // lines, for as long as the tab stayed open, while real sessions connected
170
+ // normally beside it. Answering 401 before the upgrade puts the guard back
171
+ // in play: the socket never opens, the client counts a refused handshake,
172
+ // and it stops after three. The same install went to zero refusals within
173
+ // minutes of shipping this.
174
+ //
175
+ // Only targets that declare `injectTokenFromCookie` are gated — a target
176
+ // with no token config never wanted a session. `allowUnauthenticated`
177
+ // exists for the deliberate exception.
178
+ if (!authenticated && target.allowUnauthenticated !== true) {
179
+ logger.warn(`[WS:auth] Refusing unauthenticated upgrade for ${path}`);
180
+ return new Response('WebSocket requires a session', {
181
+ status: 401
182
+ });
183
+ }
142
184
  logger.info(`Upgrading: ${path} -> ${backendUrl}`);
143
185
  // Extract original request headers to forward to backend
144
186
  const originalHeaders = {};
@@ -40,3 +40,26 @@ export declare function bodyCeilingComplaint(input: {
40
40
  configured: number | null | undefined;
41
41
  required: number | null;
42
42
  }): string | null;
43
+ /**
44
+ * The sentence to print when the POD cannot carry what the configuration
45
+ * promises, or `null` when it can.
46
+ *
47
+ * `bodyCeilingComplaint` asks whether the server will accept the bytes. This
48
+ * asks the question after it: whether the container survives holding them. They
49
+ * fail differently and both are silent — the first drops the upload, the second
50
+ * kills the process, and neither leaves a line saying the ceiling was never
51
+ * reachable.
52
+ *
53
+ * Measured on a live install: `storage.maxFileSizeBytes` set to 1 GiB on a pod
54
+ * capped at 1 GiB. The setting could not be honoured by arithmetic — the
55
+ * kernel would kill the process somewhere in the middle of the first big
56
+ * upload — and nothing anywhere said so. The frontend of the same product had
57
+ * already learned this and been raised to 2 GiB; the API had been forgotten.
58
+ *
59
+ * `null` when there is no cgroup limit to compare against, because on a host
60
+ * with the node's memory this is the operator's business.
61
+ */
62
+ export declare function memoryCeilingComplaint(input: {
63
+ required: number | null;
64
+ cgroupLimit: number | null;
65
+ }): string | null;
@@ -1,4 +1,22 @@
1
1
  import type { MonitoringConfig, SystemMetrics } from '../types';
2
+ import { type RuntimeMemoryAdvice } from './memory';
3
+ /**
4
+ * Boot-time only: does the JS engine know the ceiling the kernel will enforce?
5
+ *
6
+ * Lives beside the collector because it reads the same cgroup file, but it is
7
+ * deliberately NOT part of the sampling loop — the answer cannot change while
8
+ * the process runs. See `runtimeMemoryAdvice` for what this costs when nobody
9
+ * asks: a pod that restarts itself twice a day and no line anywhere saying why.
10
+ */
11
+ /**
12
+ * This container's memory ceiling in bytes, or `null` when it has none.
13
+ *
14
+ * Separate from `readRuntimeMemoryAdvice` on purpose: that function answers
15
+ * "should we advise a hint", and goes quiet once one is set. A caller asking
16
+ * "how much memory does this pod actually have" needs the number regardless.
17
+ */
18
+ export declare function readCgroupLimitBytes(): number | null;
19
+ export declare function readRuntimeMemoryAdvice(): RuntimeMemoryAdvice | null;
2
20
  export declare class SystemCollector {
3
21
  private config;
4
22
  private lastCpuInfo;
@@ -29,11 +29,66 @@ export interface MemoryReading {
29
29
  rss: number;
30
30
  heapUsed: number;
31
31
  heapTotal: number;
32
+ /**
33
+ * The part of `rss` that is NOT the JS heap: native buffers, and of those the
34
+ * ArrayBuffer share.
35
+ *
36
+ * Without these two a panel can show "memory 90%" and be unable to say where
37
+ * any of it is. Measured on a live install the day it started OOM-killing
38
+ * itself: rss 690 MB, heapUsed 67 MB. Ninety percent of the memory that
39
+ * ended the process was invisible to the screen built to watch it — and the
40
+ * two numbers that would have named it are already in `process.memoryUsage()`,
41
+ * free of charge, simply never read.
42
+ */
43
+ external: number;
44
+ arrayBuffers: number;
32
45
  /** Which ceiling `total` refers to, so a UI can say so out loud. */
33
46
  scope: MemoryScope;
34
47
  }
35
48
  /** `memory.max` holds the literal string "max" when the container is uncapped. */
36
49
  export declare function parseCgroupLimit(raw: string | null): number | null;
50
+ export interface RuntimeMemoryAdvice {
51
+ /** The real ceiling, from the cgroup. */
52
+ cgroupLimit: number;
53
+ /** What the JS engine believes it has — the NODE's RAM. */
54
+ hostTotal: number;
55
+ /** What `BUN_JSC_forceRAMSize` should be set to, in bytes. */
56
+ recommended: number;
57
+ }
58
+ /**
59
+ * The gap between the ceiling the kernel enforces and the one the engine believes.
60
+ *
61
+ * This service already reads the cgroup correctly — the monitoring panel says
62
+ * "1 GiB" and it is right. The garbage collector does not: `os.totalmem()`
63
+ * inside a capped container returns the NODE's memory. Measured on a live
64
+ * install: cgroup 1073741824 (1 GiB), `os.totalmem()` 33651855360 (31.3 GB) —
65
+ * a 31x overestimate. A collector that believes it is using 2% of memory feels
66
+ * no pressure at all, so it grows the heap and never returns the pages, and the
67
+ * cgroup eventually kills the process. Confirmed by the kubelet: OOMKilled,
68
+ * exit 137, after exactly 4h00m, three times over, on an install with no users.
69
+ *
70
+ * It cannot be fixed from inside the process — JSC reads its options when the
71
+ * runtime boots, long before any of this code runs. So the honest thing is to
72
+ * SAY it at startup, with the number to set, rather than let a pod quietly
73
+ * restart itself twice a day.
74
+ *
75
+ * A quarter of the limit, not the whole thing: the JS heap is a minority of RSS
76
+ * in this stack. On the same install, live objects sat at 60-90 MB while total
77
+ * RSS reached a gigabyte — the rest is native buffers, the log ring, the pg and
78
+ * redis clients. Handing the engine the entire limit just moves the OOM; a hint
79
+ * ABOVE what the heap ever reaches is a brake whose pedal is never touched, and
80
+ * that was measured too: 768 MiB on a 1 GiB pod behaved identically to no hint
81
+ * at all.
82
+ *
83
+ * Returns `null` when there is nothing to say: uncapped container, a "limit"
84
+ * that is not really a limit, or an operator who has already set a hint at or
85
+ * below the advice.
86
+ */
87
+ export declare function runtimeMemoryAdvice(input: {
88
+ cgroupLimit: number | null;
89
+ hostTotal: number;
90
+ forcedRamSize: number | null;
91
+ }): RuntimeMemoryAdvice | null;
37
92
  export declare function parseCgroupUsage(raw: string | null): number | null;
38
93
  /**
39
94
  * Reclaimable page cache inside the cgroup, from `memory.stat`.
@@ -61,6 +116,8 @@ export interface MemorySources {
61
116
  rss: number;
62
117
  heapUsed: number;
63
118
  heapTotal: number;
119
+ external: number;
120
+ arrayBuffers: number;
64
121
  };
65
122
  }
66
123
  /**
@@ -98,6 +98,15 @@ export interface SystemMetrics {
98
98
  rss: number;
99
99
  heapUsed: number;
100
100
  heapTotal: number;
101
+ /**
102
+ * Where the rest of `rss` is. A screen that reports "memory 90%" and cannot
103
+ * say what the 90% consists of sends its reader looking in the JS heap,
104
+ * which on this stack is the minority: measured on a live install at the
105
+ * point it began OOM-killing itself, rss was 690 MB and heapUsed 67 MB.
106
+ * These come free from `process.memoryUsage()`.
107
+ */
108
+ external: number;
109
+ arrayBuffers: number;
101
110
  /**
102
111
  * Whether the figures describe this container's cgroup limit or the whole
103
112
  * node. A panel must say which, or "%98" means nothing.
@@ -137,10 +146,22 @@ export interface ApplicationMetrics {
137
146
  p99: number;
138
147
  };
139
148
  errors: {
149
+ /** 5xx only — the service failed. This is what an alert should watch. */
140
150
  total: number;
141
151
  rate: number;
142
152
  byType: Record<string, number>;
143
153
  };
154
+ /**
155
+ * 4xx — callers refused, over the same minute.
156
+ *
157
+ * Split out because a 401 is the service working: it is telling a caller it
158
+ * has no valid session. Folded into `errors.rate` it produced a CRITICAL
159
+ * alert every morning on a healthy install as overnight sessions expired —
160
+ * 102 of 106 `/auth/refresh` calls answered 401, and not one 5xx anywhere.
161
+ */
162
+ clientErrors: {
163
+ rate: number;
164
+ };
144
165
  rateLimits: {
145
166
  blocked: number;
146
167
  blockedPerMinute: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.950",
3
+ "version": "0.9.952",
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",