@lenne.tech/nest-server 11.31.3 → 11.32.0
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/.claude/rules/versioning.md +5 -8
- package/CLAUDE.md +3 -3
- package/FRAMEWORK-API.md +4 -2
- package/README.md +1 -0
- package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
- package/dist/core/common/services/brevo.service.d.ts +7 -1
- package/dist/core/common/services/brevo.service.js +37 -16
- package/dist/core/common/services/brevo.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/docs/brevo-manual-test.md +166 -0
- package/docs/security-overrides.md +90 -0
- package/migration-guides/11.31.3-to-11.32.0.md +254 -0
- package/package.json +15 -14
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
- package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
- package/src/core/common/interfaces/server-options.interface.ts +32 -0
- package/src/core/common/services/brevo.service.spec.ts +266 -0
- package/src/core/common/services/brevo.service.ts +100 -17
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
- package/src/index.ts +1 -0
- package/src/main.ts +22 -3
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { handleFatalBootstrapError, installProcessDiagnostics } from './process-diagnostics.helper';
|
|
5
|
+
|
|
6
|
+
// `vi.spyOn(fs, 'writeSync')` cannot work here — an ESM module namespace is not configurable, so
|
|
7
|
+
// the property cannot be redefined. Mocking the module is the only way to observe the default
|
|
8
|
+
// sink, and the helper imports nothing else from `node:fs`.
|
|
9
|
+
const { writeSyncMock } = vi.hoisted(() => ({ writeSyncMock: vi.fn<(fd: number, data: string) => number>() }));
|
|
10
|
+
vi.mock('node:fs', () => ({ writeSync: writeSyncMock }));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression guard for the "silent exit" class of failures: a Node API under a ts-node dev
|
|
14
|
+
* runner can die printing nothing but `app crashed` — no stacktrace, which reads like a
|
|
15
|
+
* product bug and costs long, misdirected debugging sessions. The diagnostics helper makes
|
|
16
|
+
* the exit reason visible — a rejected fire-and-forget promise no longer takes the server
|
|
17
|
+
* down, an uncaught exception is logged with a clear marker before it exits, and an EXTERNAL
|
|
18
|
+
* termination signal (another tool's pkill, `lt dev down`, an OS OOM SIGTERM, Ctrl-C) is
|
|
19
|
+
* logged as such instead of masquerading as an in-process crash.
|
|
20
|
+
*
|
|
21
|
+
* The installer is tested against an injected EventEmitter so the real process (and the
|
|
22
|
+
* Vitest runner it lives in) is never signalled or exited.
|
|
23
|
+
*
|
|
24
|
+
* Repo-wiring assertions (src/main.ts, src/index.ts, nodemon.json) deliberately live in
|
|
25
|
+
* `tests/unit/process-diagnostics-wiring.spec.ts`, NOT here: this file ships inside `src/core/`
|
|
26
|
+
* and is copied verbatim into vendor-mode consumer projects, where those repo-root paths do not
|
|
27
|
+
* exist. Keeping this spec free of `process.cwd()` is what makes it portable.
|
|
28
|
+
*/
|
|
29
|
+
function setup(
|
|
30
|
+
preRegister?: Partial<Record<string, () => void>>,
|
|
31
|
+
options?: Parameters<typeof installProcessDiagnostics>[0],
|
|
32
|
+
) {
|
|
33
|
+
const target = new EventEmitter();
|
|
34
|
+
target.setMaxListeners(50);
|
|
35
|
+
const errors: string[] = [];
|
|
36
|
+
const warnings: string[] = [];
|
|
37
|
+
const logger = {
|
|
38
|
+
error: (message: string) => errors.push(message),
|
|
39
|
+
warn: (message: string) => warnings.push(message),
|
|
40
|
+
};
|
|
41
|
+
const exit = vi.fn();
|
|
42
|
+
const reraise = vi.fn();
|
|
43
|
+
if (preRegister) {
|
|
44
|
+
for (const [event, fn] of Object.entries(preRegister)) {
|
|
45
|
+
target.on(event, fn);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
installProcessDiagnostics({ exit, logger, reraise, target, ...options });
|
|
49
|
+
return { errors, exit, reraise, target, warnings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
vi.useRealTimers();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('installProcessDiagnostics', () => {
|
|
58
|
+
it('logs an unhandled rejection but keeps the process alive', () => {
|
|
59
|
+
const { exit, target, warnings } = setup();
|
|
60
|
+
target.emit('unhandledRejection', new Error('SMTP down'));
|
|
61
|
+
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('SMTP down'))).toBe(true);
|
|
62
|
+
expect(exit).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('routes an unhandled rejection to the NON-blocking sink', () => {
|
|
66
|
+
// An unhandled rejection can fire once per request while the server keeps serving. A
|
|
67
|
+
// synchronous write would block the whole event loop until stderr drains, so this path must
|
|
68
|
+
// use `warn` (async) and never `error` (sync). Nothing is terminating, so there is no
|
|
69
|
+
// last-gasp guarantee worth blocking for.
|
|
70
|
+
const { errors, target, warnings } = setup();
|
|
71
|
+
target.emit('unhandledRejection', new Error('per-request failure'));
|
|
72
|
+
expect(warnings).toHaveLength(1);
|
|
73
|
+
expect(errors).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('formats a non-Error rejection reason via String()', () => {
|
|
77
|
+
const { target, warnings } = setup();
|
|
78
|
+
target.emit('unhandledRejection', 'plain string reason');
|
|
79
|
+
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('plain string reason'))).toBe(
|
|
80
|
+
true,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('exits on an unhandled rejection when onUnhandledRejection is "exit"', () => {
|
|
85
|
+
// Restores Node >= 15's own `--unhandled-rejections=throw` default for deployments that
|
|
86
|
+
// prefer a clean restart over serving from an unknown state.
|
|
87
|
+
const { errors, exit, target } = setup(undefined, { onUnhandledRejection: 'exit' });
|
|
88
|
+
target.emit('unhandledRejection', new Error('inconsistent state'));
|
|
89
|
+
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
90
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('logs an uncaught exception with a marker and exits with code 1', () => {
|
|
94
|
+
const { errors, exit, target } = setup();
|
|
95
|
+
target.emit('uncaughtException', new Error('boom'));
|
|
96
|
+
expect(errors.some((line) => line.includes('[uncaughtException]') && line.includes('boom'))).toBe(true);
|
|
97
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does NOT print the error message twice', () => {
|
|
101
|
+
// `error.stack` already begins with `${name}: ${message}`. Prefixing the message again put the
|
|
102
|
+
// same line in the output twice, which reads like two distinct failures.
|
|
103
|
+
const { errors, target } = setup();
|
|
104
|
+
const error = new Error('duplicated');
|
|
105
|
+
target.emit('uncaughtException', error);
|
|
106
|
+
const occurrences = errors[0].split('duplicated').length - 1;
|
|
107
|
+
expect(occurrences).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('falls back to name + message when an Error carries no stack', () => {
|
|
111
|
+
const { errors, target } = setup();
|
|
112
|
+
const error = new Error('no stack here');
|
|
113
|
+
error.stack = undefined;
|
|
114
|
+
target.emit('uncaughtException', error);
|
|
115
|
+
expect(errors[0]).toContain('Error: no stack here');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does NOT register a process warning handler', () => {
|
|
119
|
+
// Node prints process warnings to stderr itself and adding a listener does not replace that —
|
|
120
|
+
// it appends a second, strictly worse copy (Node's own line carries the warning `name` and the
|
|
121
|
+
// `--trace-warnings` hint). Duplicating it doubles the volume on a path that fires during
|
|
122
|
+
// normal operation.
|
|
123
|
+
const { target } = setup();
|
|
124
|
+
expect(target.listenerCount('warning')).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('logs a non-zero exit code', () => {
|
|
128
|
+
const { errors, target } = setup();
|
|
129
|
+
target.emit('exit', 143);
|
|
130
|
+
expect(errors.some((line) => line.includes('[exit]') && line.includes('143'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('stays silent on a clean exit', () => {
|
|
134
|
+
// A clean exit is not a diagnostic. Logging it under every CLI script and every graceful
|
|
135
|
+
// shutdown trains readers to ignore the marker.
|
|
136
|
+
const { errors, target } = setup();
|
|
137
|
+
target.emit('exit', 0);
|
|
138
|
+
expect(errors).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('logs SIGTERM as external termination and re-raises it when it is the only listener', () => {
|
|
142
|
+
const { errors, exit, reraise, target } = setup();
|
|
143
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
144
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGTERM'))).toBe(true);
|
|
145
|
+
expect(reraise).toHaveBeenCalledWith('SIGTERM');
|
|
146
|
+
expect(exit).not.toHaveBeenCalled();
|
|
147
|
+
// The handler MUST remove itself before re-raising — otherwise the re-raised signal
|
|
148
|
+
// re-enters the same handler forever (infinite log-and-re-raise loop, never terminates).
|
|
149
|
+
expect(target.listenerCount('SIGTERM')).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('logs SIGINT and re-raises it when it is the only listener', () => {
|
|
153
|
+
const { errors, reraise, target } = setup();
|
|
154
|
+
target.emit('SIGINT', 'SIGINT');
|
|
155
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGINT'))).toBe(true);
|
|
156
|
+
expect(reraise).toHaveBeenCalledWith('SIGINT');
|
|
157
|
+
expect(target.listenerCount('SIGINT')).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each(['SIGHUP', 'SIGQUIT'] as const)('labels %s as an external termination too', (signal) => {
|
|
161
|
+
// A closed terminal (SIGHUP) or a SIGQUIT from an orchestrator used to terminate unannotated,
|
|
162
|
+
// producing exactly the stackless death this helper exists to explain.
|
|
163
|
+
const { errors, reraise, target } = setup();
|
|
164
|
+
target.emit(signal, signal);
|
|
165
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes(signal))).toBe(true);
|
|
166
|
+
expect(reraise).toHaveBeenCalledWith(signal);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does NOT touch SIGUSR2', () => {
|
|
170
|
+
// nodemon restarts the app with SIGUSR2. Attaching a listener overrides its disposition, and
|
|
171
|
+
// labelling a restart is not worth changing how restarts behave.
|
|
172
|
+
const { target } = setup();
|
|
173
|
+
expect(target.listenerCount('SIGUSR2')).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does NOT re-raise a signal when a graceful-shutdown handler is also registered', () => {
|
|
177
|
+
// `app.enableShutdownHooks()` registers its own SIGTERM/SIGINT listener. Re-raising on top of
|
|
178
|
+
// it would kill the process mid-shutdown and defeat the graceful teardown.
|
|
179
|
+
const other = vi.fn();
|
|
180
|
+
const { errors, reraise, target } = setup({ SIGTERM: other }, { shutdownTimeoutMs: 0 });
|
|
181
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
182
|
+
expect(errors.some((line) => line.includes('[signal]'))).toBe(true);
|
|
183
|
+
expect(errors.some((line) => line.includes('another handler owns the shutdown'))).toBe(true);
|
|
184
|
+
expect(reraise).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('forces an exit when the co-listener never finishes the shutdown', () => {
|
|
188
|
+
// Abdicating unconditionally means a co-listener that never terminates makes SIGTERM a
|
|
189
|
+
// permanent no-op while the log line claims the process is going down — only SIGKILL would
|
|
190
|
+
// still work. The watchdog bounds that.
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
const { errors, exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 30_000 });
|
|
193
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
194
|
+
expect(exit).not.toHaveBeenCalled();
|
|
195
|
+
vi.advanceTimersByTime(30_000);
|
|
196
|
+
expect(errors.some((line) => line.includes('shutdown watchdog expired'))).toBe(true);
|
|
197
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not arm the watchdog when shutdownTimeoutMs is 0', () => {
|
|
201
|
+
vi.useFakeTimers();
|
|
202
|
+
const { exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 0 });
|
|
203
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
204
|
+
vi.advanceTimersByTime(120_000);
|
|
205
|
+
expect(exit).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('is idempotent — installing twice does not stack duplicate handlers', () => {
|
|
209
|
+
const target = new EventEmitter();
|
|
210
|
+
const logger = { error: () => undefined };
|
|
211
|
+
const exit = vi.fn();
|
|
212
|
+
const reraise = vi.fn();
|
|
213
|
+
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
214
|
+
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
215
|
+
expect(target.listenerCount('uncaughtException')).toBe(1);
|
|
216
|
+
expect(target.listenerCount('unhandledRejection')).toBe(1);
|
|
217
|
+
expect(target.listenerCount('SIGTERM')).toBe(1);
|
|
218
|
+
expect(target.listenerCount('SIGINT')).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('falls back to the error sink when a custom logger provides no warn', () => {
|
|
222
|
+
const target = new EventEmitter();
|
|
223
|
+
const errors: string[] = [];
|
|
224
|
+
installProcessDiagnostics({
|
|
225
|
+
exit: vi.fn(),
|
|
226
|
+
logger: { error: (message: string) => errors.push(message) },
|
|
227
|
+
reraise: vi.fn(),
|
|
228
|
+
target,
|
|
229
|
+
});
|
|
230
|
+
target.emit('unhandledRejection', new Error('no warn sink'));
|
|
231
|
+
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('redaction and truncation', () => {
|
|
236
|
+
it('masks a JWT and truncates an oversized line', () => {
|
|
237
|
+
const target = new EventEmitter();
|
|
238
|
+
const warnings: string[] = [];
|
|
239
|
+
installProcessDiagnostics({
|
|
240
|
+
exit: vi.fn(),
|
|
241
|
+
logger: { error: () => undefined, warn: (message: string) => warnings.push(message) },
|
|
242
|
+
reraise: vi.fn(),
|
|
243
|
+
target,
|
|
244
|
+
});
|
|
245
|
+
const jwt = `eyJhbGciOiJIUzI1NiJ9.${'a'.repeat(40)}.${'b'.repeat(20)}`;
|
|
246
|
+
const error = new Error(`token=${jwt} ${'x'.repeat(30_000)}`);
|
|
247
|
+
error.stack = `Error: token=${jwt} ${'x'.repeat(30_000)}`;
|
|
248
|
+
target.emit('unhandledRejection', error);
|
|
249
|
+
|
|
250
|
+
expect(warnings).toHaveLength(1);
|
|
251
|
+
expect(warnings[0]).not.toContain(jwt);
|
|
252
|
+
expect(warnings[0]).toContain('[truncated]');
|
|
253
|
+
expect(warnings[0].length).toBeLessThan(20_000);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('defaultDiagnosticsLogger', () => {
|
|
258
|
+
it('writes synchronously to fd 2 so a last-gasp line cannot be truncated', () => {
|
|
259
|
+
// console.error is asynchronous on a pipe, so a line written immediately before process.exit()
|
|
260
|
+
// can be dropped — silently losing the very line this helper exists to emit.
|
|
261
|
+
writeSyncMock.mockReset();
|
|
262
|
+
writeSyncMock.mockReturnValue(0);
|
|
263
|
+
const target = new EventEmitter();
|
|
264
|
+
installProcessDiagnostics({ exit: vi.fn(), reraise: vi.fn(), target });
|
|
265
|
+
target.emit('uncaughtException', new Error('sync sink'));
|
|
266
|
+
|
|
267
|
+
expect(writeSyncMock).toHaveBeenCalled();
|
|
268
|
+
const [fd, payload] = writeSyncMock.mock.calls[0];
|
|
269
|
+
expect(fd).toBe(2);
|
|
270
|
+
expect(String(payload)).toContain('[uncaughtException]');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('survives a broken stderr instead of escalating', () => {
|
|
274
|
+
// EBADF (fd 2 closed), EPIPE (reader exited) and EAGAIN (non-blocking pipe) all make writeSync
|
|
275
|
+
// throw. A throw raised INSIDE the uncaughtException handler makes Node exit 7 and print its
|
|
276
|
+
// own "throw inside handler" message — losing the original error entirely.
|
|
277
|
+
writeSyncMock.mockReset();
|
|
278
|
+
writeSyncMock.mockImplementation(() => {
|
|
279
|
+
throw Object.assign(new Error('EBADF: bad file descriptor'), { code: 'EBADF' });
|
|
280
|
+
});
|
|
281
|
+
const exit = vi.fn();
|
|
282
|
+
const target = new EventEmitter();
|
|
283
|
+
installProcessDiagnostics({ exit, reraise: vi.fn(), target });
|
|
284
|
+
|
|
285
|
+
expect(() => target.emit('uncaughtException', new Error('original failure'))).not.toThrow();
|
|
286
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('handleFatalBootstrapError', () => {
|
|
291
|
+
it('logs a failed bootstrap with a marker and exits with code 1', () => {
|
|
292
|
+
const errors: string[] = [];
|
|
293
|
+
const logger = { error: (message: string) => errors.push(message) };
|
|
294
|
+
const exit = vi.fn();
|
|
295
|
+
handleFatalBootstrapError(new Error('listen EADDRINUSE: address already in use'), { exit, logger });
|
|
296
|
+
expect(errors.some((line) => line.includes('[bootstrap]') && line.includes('EADDRINUSE'))).toBe(true);
|
|
297
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('redacts secrets out of a startup error before logging it', () => {
|
|
301
|
+
// The documented motivating failures ("DB unreachable") carry connection strings.
|
|
302
|
+
const errors: string[] = [];
|
|
303
|
+
const exit = vi.fn();
|
|
304
|
+
handleFatalBootstrapError(new Error('connect failed: password=hunter2'), {
|
|
305
|
+
exit,
|
|
306
|
+
logger: { error: (message: string) => errors.push(message) },
|
|
307
|
+
});
|
|
308
|
+
expect(errors[0]).not.toContain('hunter2');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level exit diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* A Node API can die "silently": the dev runner (`nodemon`, `lt dev`) prints only
|
|
5
|
+
* `app crashed - waiting for file changes before starting...` with NO stacktrace, and in a
|
|
6
|
+
* container the process simply vanishes. A missing stacktrace means the process was killed by a
|
|
7
|
+
* SIGNAL rather than throwing — either an OS OOM SIGKILL (uncatchable) or an EXTERNAL
|
|
8
|
+
* SIGTERM/SIGINT (an orchestrator's stop, another tool's pkill, `lt dev down`, Ctrl-C).
|
|
9
|
+
*
|
|
10
|
+
* `installProcessDiagnostics()` makes the exit reason visible:
|
|
11
|
+
* - `unhandledRejection` is logged but NOT fatal by default — a rejected fire-and-forget promise
|
|
12
|
+
* (e.g. a transactional email failing on an auth path outside a try/catch) must never take the
|
|
13
|
+
* whole server down. Set `onUnhandledRejection: 'exit'` to restore Node's own fail-fast.
|
|
14
|
+
* - `uncaughtException` is logged with a clear marker and then exits(1) — process state is unknown
|
|
15
|
+
* after one, so crashing and being restarted is the safe behaviour, but with a guaranteed log
|
|
16
|
+
* line above the runner's "app crashed".
|
|
17
|
+
* - a NON-ZERO `exit` code is logged (a clean exit needs no explanation and no extra line).
|
|
18
|
+
* - termination signals are logged as EXTERNAL terminations so they stop masquerading as
|
|
19
|
+
* in-process crashes.
|
|
20
|
+
*
|
|
21
|
+
* **Deliberately NOT handled: process `warning`s.** Node prints them to stderr itself, and adding a
|
|
22
|
+
* listener does not replace that — it appends a second, strictly worse copy (Node's own line
|
|
23
|
+
* carries the warning `name` and the `--trace-warnings` hint; ours would not). Duplicating it would
|
|
24
|
+
* double the volume on the one path that fires during normal operation. Use `--trace-warnings`
|
|
25
|
+
* instead when a warning needs a stack.
|
|
26
|
+
*
|
|
27
|
+
* **Call it from `main.ts`, not from a module.** It is deliberately NOT wired into
|
|
28
|
+
* `CoreModule.forRoot()`:
|
|
29
|
+
* 1. It must run BEFORE `NestFactory.create()` to catch failures during module construction —
|
|
30
|
+
* inside a module it would already be too late.
|
|
31
|
+
* 2. It installs a `process.exit(1)` path. Registering that from a module would also arm it inside
|
|
32
|
+
* `Test.createTestingModule()`, where an uncaught exception would then kill the whole test
|
|
33
|
+
* runner instead of failing one spec.
|
|
34
|
+
*
|
|
35
|
+
* **Known limitation:** it cannot cover failures during synchronous *import* evaluation of
|
|
36
|
+
* `main.ts` (a throwing `config.env.ts`, an SWC temporal-dead-zone `ReferenceError`), because
|
|
37
|
+
* imports are hoisted above the first statement of `bootstrap()`. That class of failure is loud
|
|
38
|
+
* anyway — Node prints it with a full stack. Only a `--require` preload module could cover it.
|
|
39
|
+
*
|
|
40
|
+
* **Pair it with `enableShutdownHooks()`.** On its own this helper labels a signal; it does not
|
|
41
|
+
* make the process terminate on one. In a container Node is PID 1, where a userspace re-raise with
|
|
42
|
+
* the default disposition is silently discarded by the kernel — the event loop stays busy and
|
|
43
|
+
* `docker stop` waits out its grace period before SIGKILL. `app.enableShutdownHooks()` is what
|
|
44
|
+
* actually drains the loop, and this helper then correctly defers to it.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* // src/main.ts
|
|
49
|
+
* import { handleFatalBootstrapError, installProcessDiagnostics } from '@lenne.tech/nest-server';
|
|
50
|
+
*
|
|
51
|
+
* async function bootstrap() {
|
|
52
|
+
* installProcessDiagnostics();
|
|
53
|
+
* const server = await NestFactory.create<NestExpressApplication>(ServerModule);
|
|
54
|
+
* server.enableShutdownHooks(); // required for a graceful container stop
|
|
55
|
+
* await server.listen(envConfig.port);
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* // A rejection here is a fatal startup failure — exit instead of leaving a zombie process.
|
|
59
|
+
* bootstrap().catch(handleFatalBootstrapError);
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { writeSync } from 'node:fs';
|
|
64
|
+
|
|
65
|
+
import { redactSensitiveText } from './logging.helper';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hard cap for a single diagnostic line.
|
|
69
|
+
*
|
|
70
|
+
* `describeError` renders a caller-controlled value — a rejected promise can carry an object whose
|
|
71
|
+
* `toString()` returns megabytes. One unbounded synchronous write would stall the event loop for
|
|
72
|
+
* its whole duration.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_DIAGNOSTIC_CHARS = 16_384;
|
|
75
|
+
|
|
76
|
+
/** Minimal logger surface — `console` satisfies it. */
|
|
77
|
+
export interface DiagnosticsLogger {
|
|
78
|
+
/**
|
|
79
|
+
* Sink for TERMINAL diagnostics (uncaught exception, non-zero exit, signals, fatal bootstrap).
|
|
80
|
+
* These are written immediately before the process goes away, so the default implementation is
|
|
81
|
+
* synchronous.
|
|
82
|
+
*/
|
|
83
|
+
error: (message: string) => void;
|
|
84
|
+
/**
|
|
85
|
+
* Sink for NON-TERMINAL diagnostics (an unhandled rejection while the server keeps serving).
|
|
86
|
+
* Defaults to `error` when omitted. The default implementation is ASYNCHRONOUS on purpose — see
|
|
87
|
+
* {@link defaultDiagnosticsLogger}.
|
|
88
|
+
*/
|
|
89
|
+
warn?: (message: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Caps and redacts a diagnostic line before it reaches any sink.
|
|
94
|
+
*
|
|
95
|
+
* Redaction matters because this sink deliberately bypasses the Nest logger, and with it every
|
|
96
|
+
* structured-log redaction a consumer has configured. The framework's own `redactSensitiveText`
|
|
97
|
+
* is the same one the Hub log buffer uses, so a connection string or bearer token in an error
|
|
98
|
+
* message is masked here exactly as it would be there.
|
|
99
|
+
*
|
|
100
|
+
* @param message - The raw diagnostic line
|
|
101
|
+
* @returns The line, truncated to {@link MAX_DIAGNOSTIC_CHARS} and redacted
|
|
102
|
+
*/
|
|
103
|
+
function formatDiagnostic(message: string): string {
|
|
104
|
+
// Cap BEFORE redacting: only the truncated head is ever emitted, so redacting the discarded
|
|
105
|
+
// tail would be wasted work on the very path that must stay cheap.
|
|
106
|
+
const capped =
|
|
107
|
+
message.length > MAX_DIAGNOSTIC_CHARS ? `${message.slice(0, MAX_DIAGNOSTIC_CHARS)}… [truncated]` : message;
|
|
108
|
+
return redactSensitiveText(capped);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Default diagnostic sink.
|
|
113
|
+
*
|
|
114
|
+
* `error` is a SYNCHRONOUS write to fd 2 (stderr). `console.error` is asynchronous on a pipe, so a
|
|
115
|
+
* last-gasp line written immediately before `process.exit()` or a signal re-raise can be truncated
|
|
116
|
+
* — silently dropping the very line this helper exists to emit. `writeSync` completes before we
|
|
117
|
+
* terminate.
|
|
118
|
+
*
|
|
119
|
+
* `warn` is deliberately ASYNCHRONOUS. A synchronous write blocks the whole event loop until the
|
|
120
|
+
* pipe drains, so on the one path that fires while the server is still serving — an unhandled
|
|
121
|
+
* rejection, potentially once per request — a stalled log collector would stall the entire process.
|
|
122
|
+
* Nothing is about to terminate there, so there is no last-gasp guarantee to preserve.
|
|
123
|
+
*
|
|
124
|
+
* Both bypass the Nest logger (including any JSON logger configured for production): these lines
|
|
125
|
+
* must survive the exact moments when the DI container is being torn down or is already gone.
|
|
126
|
+
* Structured-log consumers see them as plain stderr records. Pass a custom `logger` if they must be
|
|
127
|
+
* routed elsewhere.
|
|
128
|
+
*/
|
|
129
|
+
const defaultDiagnosticsLogger: DiagnosticsLogger = {
|
|
130
|
+
error: (message: string) => {
|
|
131
|
+
try {
|
|
132
|
+
writeSync(2, `${message}\n`);
|
|
133
|
+
} catch {
|
|
134
|
+
// EBADF (fd 2 closed), EPIPE (reader exited — routine under `| head` or a detached log
|
|
135
|
+
// driver), EAGAIN (non-blocking pipe under backpressure). A diagnostic line is best-effort:
|
|
136
|
+
// throwing here would escalate. Worse, a throw raised INSIDE the uncaughtException handler
|
|
137
|
+
// makes Node exit 7 and print its own "throw inside handler" message instead of the original
|
|
138
|
+
// error — inverting the entire purpose of this helper.
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
warn: (message: string) => {
|
|
142
|
+
console.error(message);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Options for {@link installProcessDiagnostics}. All dependencies are injectable for testing. */
|
|
147
|
+
export interface ProcessDiagnosticsOptions {
|
|
148
|
+
/** Process-exit function. Defaults to `process.exit`. Injected in tests. */
|
|
149
|
+
exit?: (code: number) => void;
|
|
150
|
+
/** Logger for the diagnostic lines. Defaults to a synchronous stderr sink. */
|
|
151
|
+
logger?: DiagnosticsLogger;
|
|
152
|
+
/**
|
|
153
|
+
* What to do on an unhandled rejection.
|
|
154
|
+
*
|
|
155
|
+
* - `'log'` (default): log and continue — a fire-and-forget failure must not take the server down.
|
|
156
|
+
* - `'exit'`: log and `exit(1)`, restoring Node >= 15's own `--unhandled-rejections=throw`
|
|
157
|
+
* default for deployments that prefer a clean restart over serving from an unknown state.
|
|
158
|
+
*
|
|
159
|
+
* @default 'log'
|
|
160
|
+
*/
|
|
161
|
+
onUnhandledRejection?: 'exit' | 'log';
|
|
162
|
+
/** Re-raise a signal with its default disposition. Defaults to `process.kill(process.pid, signal)`. */
|
|
163
|
+
reraise?: (signal: NodeJS.Signals) => void;
|
|
164
|
+
/**
|
|
165
|
+
* How long to wait for another signal handler (e.g. `enableShutdownHooks()`) to finish the
|
|
166
|
+
* shutdown before forcing an exit. `0` disables the watchdog.
|
|
167
|
+
*
|
|
168
|
+
* Without it, a co-listener that never terminates makes SIGTERM a permanent no-op while the log
|
|
169
|
+
* line claims the process is going down — only SIGKILL would still work.
|
|
170
|
+
*
|
|
171
|
+
* @default 30000
|
|
172
|
+
*/
|
|
173
|
+
shutdownTimeoutMs?: number;
|
|
174
|
+
/** Event target to attach handlers to. Defaults to the global `process`. Injected in tests. */
|
|
175
|
+
target?: NodeJS.EventEmitter;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Signals whose default disposition is to terminate the process.
|
|
180
|
+
*
|
|
181
|
+
* `SIGUSR2` is deliberately absent: nodemon uses it to trigger a restart, and attaching a listener
|
|
182
|
+
* overrides its disposition. Labelling a restart is not worth the risk of changing how it behaves.
|
|
183
|
+
*/
|
|
184
|
+
const TERMINATION_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'];
|
|
185
|
+
|
|
186
|
+
/** Guards against double-installation (e.g. an accidental second bootstrap call). */
|
|
187
|
+
const installedTargets = new WeakSet<object>();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Renders any thrown value as a loggable string, preserving the stack when there is one.
|
|
191
|
+
*
|
|
192
|
+
* @param value - The thrown / rejected value (not necessarily an `Error`)
|
|
193
|
+
* @returns The stack for an `Error` (it already begins with `name: message`), `String(value)` otherwise
|
|
194
|
+
*/
|
|
195
|
+
function describeError(value: unknown): string {
|
|
196
|
+
if (value instanceof Error) {
|
|
197
|
+
// `stack` already starts with `${name}: ${message}` — prefixing the message again would print
|
|
198
|
+
// it twice. Fall back to that same shape when a stack is missing.
|
|
199
|
+
return value.stack ?? `${value.name}: ${value.message}`;
|
|
200
|
+
}
|
|
201
|
+
return String(value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Attaches the process-level diagnostic handlers. Idempotent per target — calling it twice does
|
|
206
|
+
* not stack duplicate listeners.
|
|
207
|
+
*
|
|
208
|
+
* Call this as the first statement of `bootstrap()`, before `NestFactory.create()`. See the module
|
|
209
|
+
* docblock for why it must not live inside `CoreModule.forRoot()`.
|
|
210
|
+
*
|
|
211
|
+
* @param options - Injectable dependencies; defaults target the real `process`
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* installProcessDiagnostics();
|
|
215
|
+
* installProcessDiagnostics({ onUnhandledRejection: 'exit' }); // restore Node's fail-fast
|
|
216
|
+
*/
|
|
217
|
+
export function installProcessDiagnostics(options: ProcessDiagnosticsOptions = {}): void {
|
|
218
|
+
const target: NodeJS.EventEmitter = options.target ?? process;
|
|
219
|
+
const logger: DiagnosticsLogger = options.logger ?? defaultDiagnosticsLogger;
|
|
220
|
+
const exit: (code: number) => void = options.exit ?? ((code: number) => process.exit(code));
|
|
221
|
+
const reraise: (signal: NodeJS.Signals) => void =
|
|
222
|
+
options.reraise ?? ((signal: NodeJS.Signals) => process.kill(process.pid, signal));
|
|
223
|
+
const onUnhandledRejection: 'exit' | 'log' = options.onUnhandledRejection ?? 'log';
|
|
224
|
+
const shutdownTimeoutMs: number = options.shutdownTimeoutMs ?? 30_000;
|
|
225
|
+
|
|
226
|
+
// Non-terminal lines go through `warn` (async, cannot block the event loop); everything else
|
|
227
|
+
// through `error` (sync, survives teardown). A custom logger may omit `warn` — then it opts into
|
|
228
|
+
// its own `error` for both.
|
|
229
|
+
const logTerminal = (message: string): void => logger.error(formatDiagnostic(message));
|
|
230
|
+
const logNonTerminal = (message: string): void => (logger.warn ?? logger.error)(formatDiagnostic(message));
|
|
231
|
+
|
|
232
|
+
if (installedTargets.has(target)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
installedTargets.add(target);
|
|
236
|
+
|
|
237
|
+
target.on('unhandledRejection', (reason: unknown) => {
|
|
238
|
+
if (onUnhandledRejection === 'exit') {
|
|
239
|
+
logTerminal(`[unhandledRejection] ${describeError(reason)}`);
|
|
240
|
+
exit(1);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
logNonTerminal(`[unhandledRejection] ${describeError(reason)}`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
target.on('uncaughtException', (error: unknown) => {
|
|
247
|
+
logTerminal(`[uncaughtException] ${describeError(error)}`);
|
|
248
|
+
exit(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
target.on('exit', (code: unknown) => {
|
|
252
|
+
// A clean exit is not a diagnostic. Logging it unconditionally puts a line under every CLI
|
|
253
|
+
// script and every graceful shutdown, which trains readers to ignore the marker.
|
|
254
|
+
if (code !== 0) {
|
|
255
|
+
logTerminal(`[exit] process exiting with code ${String(code)}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
for (const signal of TERMINATION_SIGNALS) {
|
|
260
|
+
const handler = (received: NodeJS.Signals): void => {
|
|
261
|
+
// Attaching a listener overrode Node's default terminate disposition. When we are the only
|
|
262
|
+
// listener, re-raise the signal so the process still terminates as it would have. If another
|
|
263
|
+
// handler is also registered (e.g. `app.enableShutdownHooks()`), let it own the exit.
|
|
264
|
+
const alone = target.listenerCount(signal) <= 1;
|
|
265
|
+
logTerminal(
|
|
266
|
+
`[signal] received ${received} — external termination (not an in-process crash)${
|
|
267
|
+
alone ? '' : ' — another handler owns the shutdown'
|
|
268
|
+
}`,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (alone) {
|
|
272
|
+
target.removeListener(signal, handler);
|
|
273
|
+
reraise(received);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (shutdownTimeoutMs > 0) {
|
|
278
|
+
// The co-listener owns the exit — but it may never reach one. Do not stay hostage forever:
|
|
279
|
+
// an unkillable-by-SIGTERM process is worse than an ungraceful one.
|
|
280
|
+
const watchdog = setTimeout(() => {
|
|
281
|
+
logTerminal(`[signal] shutdown watchdog expired after ${shutdownTimeoutMs}ms — forcing exit`);
|
|
282
|
+
exit(1);
|
|
283
|
+
}, shutdownTimeoutMs);
|
|
284
|
+
// Never keep the event loop alive purely for the watchdog.
|
|
285
|
+
watchdog.unref?.();
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
target.on(signal, handler);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Options for {@link handleFatalBootstrapError}. All dependencies are injectable for testing. */
|
|
293
|
+
export interface FatalBootstrapErrorOptions {
|
|
294
|
+
/** Process-exit function. Defaults to `process.exit`. Injected in tests. */
|
|
295
|
+
exit?: (code: number) => void;
|
|
296
|
+
/** Logger for the diagnostic line. Defaults to a synchronous stderr sink. */
|
|
297
|
+
logger?: DiagnosticsLogger;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Terminal handler for a failed server bootstrap. Use it as the `catch` of the fire-and-forget
|
|
302
|
+
* `bootstrap()` call in `main.ts`.
|
|
303
|
+
*
|
|
304
|
+
* Without it, a rejection from `bootstrap()` (e.g. `server.listen` failing with EADDRINUSE, or a
|
|
305
|
+
* MongoDB connection error) surfaces as a mere `unhandledRejection` — which the runtime guard logs
|
|
306
|
+
* but does NOT act on, leaving a ZOMBIE process that is "alive" but listening on nothing. A startup
|
|
307
|
+
* failure is fatal: log it loudly and exit so the supervisor (nodemon in dev, the container runtime
|
|
308
|
+
* in production) restarts a clean instance.
|
|
309
|
+
*
|
|
310
|
+
* @param error - The rejection value from `bootstrap()`
|
|
311
|
+
* @param options - Injectable dependencies; defaults exit the real process
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* bootstrap().catch(handleFatalBootstrapError);
|
|
315
|
+
*/
|
|
316
|
+
export function handleFatalBootstrapError(error: unknown, options: FatalBootstrapErrorOptions = {}): void {
|
|
317
|
+
const logger: DiagnosticsLogger = options.logger ?? defaultDiagnosticsLogger;
|
|
318
|
+
const exit: (code: number) => void = options.exit ?? ((code: number) => process.exit(code));
|
|
319
|
+
logger.error(formatDiagnostic(`[bootstrap] fatal startup error — exiting: ${describeError(error)}`));
|
|
320
|
+
exit(1);
|
|
321
|
+
}
|
|
@@ -1520,6 +1520,19 @@ export interface IServerOptions {
|
|
|
1520
1520
|
*/
|
|
1521
1521
|
exclude?: RegExp;
|
|
1522
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* How often the SDK retries a failed send (408 / 429 / 5xx).
|
|
1525
|
+
*
|
|
1526
|
+
* The Brevo SDK defaults to 2 retries and honours `Retry-After` with a cap of 60 SECONDS per
|
|
1527
|
+
* attempt. `BrevoService.sendMail()` is awaited inside request handlers (e.g. the BetterAuth
|
|
1528
|
+
* email-verification hook), so a rate-limited Brevo can otherwise park a user-facing HTTP
|
|
1529
|
+
* request for roughly two minutes. The framework default of `0` keeps the request fast and
|
|
1530
|
+
* leaves retrying to the caller.
|
|
1531
|
+
*
|
|
1532
|
+
* @default 0
|
|
1533
|
+
*/
|
|
1534
|
+
maxRetries?: number;
|
|
1535
|
+
|
|
1523
1536
|
/**
|
|
1524
1537
|
* Default sender for Brevo
|
|
1525
1538
|
*/
|
|
@@ -1527,6 +1540,25 @@ export interface IServerOptions {
|
|
|
1527
1540
|
email: string;
|
|
1528
1541
|
name: string;
|
|
1529
1542
|
};
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Whether a failed send should throw instead of resolving to `null`.
|
|
1546
|
+
*
|
|
1547
|
+
* `false` (default) preserves the historical contract: errors are logged and the method
|
|
1548
|
+
* resolves to `null`. Set `true` when the caller must not proceed on a silent failure — the
|
|
1549
|
+
* framework's own email-verification path checks the `null` either way.
|
|
1550
|
+
*
|
|
1551
|
+
* @default false
|
|
1552
|
+
*/
|
|
1553
|
+
throwOnError?: boolean;
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Per-request timeout in seconds. Unset means no timeout — a hung connection is then bounded
|
|
1557
|
+
* only by the HTTP agent's own (300 s) limits, multiplied by `maxRetries`.
|
|
1558
|
+
*
|
|
1559
|
+
* @default 10
|
|
1560
|
+
*/
|
|
1561
|
+
timeoutInSeconds?: number;
|
|
1530
1562
|
};
|
|
1531
1563
|
|
|
1532
1564
|
/**
|