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.
- package/dist/.build-ok +1 -1
- package/dist/index.js +7 -7
- package/dist/src/Client/Proxy/types.d.ts +14 -0
- package/dist/src/Client/Proxy/wsProxy.js +45 -3
- package/dist/src/ElysiaPlugin/routes/shared/bodyCeiling.d.ts +23 -0
- package/dist/src/Services/Monitoring/collectors/SystemCollector.d.ts +18 -0
- package/dist/src/Services/Monitoring/collectors/memory.d.ts +57 -0
- package/dist/src/Services/Monitoring/types.d.ts +21 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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",
|