@chidchanun/bcp 0.1.26 → 0.1.28

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.
@@ -0,0 +1,374 @@
1
+ import type * as http from "node:http";
2
+
3
+ export interface ProductionHardeningConfig {
4
+ requestTimeoutMs: number;
5
+ headersTimeoutMs: number;
6
+ keepAliveTimeoutMs: number;
7
+ shutdownTimeoutMs: number;
8
+ trustProxy: boolean;
9
+ }
10
+
11
+ export interface GracefulCloseOptions {
12
+ timeoutMs?: number;
13
+ }
14
+
15
+ export interface ShutdownHookOptions {
16
+ name?: string;
17
+ }
18
+
19
+ export type ShutdownHook =
20
+ () => void | Promise<void>;
21
+
22
+ interface RegisteredShutdownHook {
23
+ id: number;
24
+ name: string;
25
+ hook: ShutdownHook;
26
+ }
27
+
28
+ const DEFAULT_REQUEST_TIMEOUT_MS =
29
+ 120_000;
30
+ const DEFAULT_HEADERS_TIMEOUT_MS =
31
+ 66_000;
32
+ const DEFAULT_KEEP_ALIVE_TIMEOUT_MS =
33
+ 65_000;
34
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS =
35
+ 10_000;
36
+
37
+ let nextShutdownHookId =
38
+ 1;
39
+ const shutdownHooks =
40
+ new Map<
41
+ number,
42
+ RegisteredShutdownHook
43
+ >();
44
+
45
+ export function getProductionHardeningConfig(
46
+ environment: NodeJS.ProcessEnv = process.env
47
+ ): ProductionHardeningConfig {
48
+ const keepAliveTimeoutMs =
49
+ parsePositiveInteger(
50
+ environment.BCP_KEEP_ALIVE_TIMEOUT_MS,
51
+ "BCP_KEEP_ALIVE_TIMEOUT_MS"
52
+ ) ??
53
+ DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
54
+ const headersTimeoutMs =
55
+ parsePositiveInteger(
56
+ environment.BCP_HEADERS_TIMEOUT_MS,
57
+ "BCP_HEADERS_TIMEOUT_MS"
58
+ ) ??
59
+ DEFAULT_HEADERS_TIMEOUT_MS;
60
+
61
+ if (
62
+ headersTimeoutMs <=
63
+ keepAliveTimeoutMs
64
+ ) {
65
+ throw new Error(
66
+ "BCP Framework: BCP_HEADERS_TIMEOUT_MS must be greater than BCP_KEEP_ALIVE_TIMEOUT_MS."
67
+ );
68
+ }
69
+
70
+ return {
71
+ requestTimeoutMs:
72
+ parsePositiveInteger(
73
+ environment.BCP_REQUEST_TIMEOUT_MS,
74
+ "BCP_REQUEST_TIMEOUT_MS"
75
+ ) ??
76
+ DEFAULT_REQUEST_TIMEOUT_MS,
77
+ headersTimeoutMs,
78
+ keepAliveTimeoutMs,
79
+ shutdownTimeoutMs:
80
+ parsePositiveInteger(
81
+ environment.BCP_SHUTDOWN_TIMEOUT_MS,
82
+ "BCP_SHUTDOWN_TIMEOUT_MS"
83
+ ) ??
84
+ DEFAULT_SHUTDOWN_TIMEOUT_MS,
85
+ trustProxy:
86
+ parseBoolean(
87
+ environment.BCP_TRUST_PROXY,
88
+ "BCP_TRUST_PROXY"
89
+ ) ??
90
+ false,
91
+ };
92
+ }
93
+
94
+ export function applyProductionHttpServerHardening(
95
+ server: http.Server,
96
+ config:
97
+ ProductionHardeningConfig =
98
+ getProductionHardeningConfig()
99
+ ): void {
100
+ server.requestTimeout =
101
+ config.requestTimeoutMs;
102
+ server.headersTimeout =
103
+ config.headersTimeoutMs;
104
+ server.keepAliveTimeout =
105
+ config.keepAliveTimeoutMs;
106
+ }
107
+
108
+ export async function closeHttpServerGracefully(
109
+ server: http.Server,
110
+ options: GracefulCloseOptions = {}
111
+ ): Promise<void> {
112
+ if (!server.listening) {
113
+ return;
114
+ }
115
+
116
+ const timeoutMs =
117
+ options.timeoutMs ??
118
+ getProductionHardeningConfig()
119
+ .shutdownTimeoutMs;
120
+
121
+ if (
122
+ !Number.isSafeInteger(
123
+ timeoutMs
124
+ ) ||
125
+ timeoutMs <= 0
126
+ ) {
127
+ throw new TypeError(
128
+ "BCP Framework: graceful shutdown timeout must be a positive safe integer."
129
+ );
130
+ }
131
+
132
+ await new Promise<void>(
133
+ (
134
+ resolve,
135
+ reject
136
+ ) => {
137
+ let settled =
138
+ false;
139
+ let timer:
140
+ NodeJS.Timeout | null =
141
+ null;
142
+ const settle =
143
+ (
144
+ error?: Error
145
+ ) => {
146
+ if (settled) {
147
+ return;
148
+ }
149
+
150
+ settled =
151
+ true;
152
+
153
+ if (timer) {
154
+ clearTimeout(
155
+ timer
156
+ );
157
+ timer =
158
+ null;
159
+ }
160
+
161
+ if (error) {
162
+ reject(
163
+ error
164
+ );
165
+ return;
166
+ }
167
+
168
+ resolve();
169
+ };
170
+
171
+ timer =
172
+ setTimeout(
173
+ () => {
174
+ try {
175
+ server.closeAllConnections?.();
176
+ } catch {
177
+ // The close callback remains authoritative.
178
+ }
179
+ },
180
+ timeoutMs
181
+ );
182
+
183
+ timer.unref?.();
184
+
185
+ try {
186
+ server.closeIdleConnections?.();
187
+ server.close(
188
+ (error) => {
189
+ settle(
190
+ error ??
191
+ undefined
192
+ );
193
+ }
194
+ );
195
+ } catch (error) {
196
+ settle(
197
+ error instanceof Error
198
+ ? error
199
+ : new Error(
200
+ String(error)
201
+ )
202
+ );
203
+ }
204
+ }
205
+ );
206
+ }
207
+
208
+ export function registerShutdownHook(
209
+ hook: ShutdownHook,
210
+ options: ShutdownHookOptions = {}
211
+ ): () => void {
212
+ if (
213
+ typeof hook !==
214
+ "function"
215
+ ) {
216
+ throw new TypeError(
217
+ "BCP Framework: shutdown hook must be a function."
218
+ );
219
+ }
220
+
221
+ const id =
222
+ nextShutdownHookId++;
223
+ const name =
224
+ normalizeHookName(
225
+ options.name,
226
+ id
227
+ );
228
+
229
+ shutdownHooks.set(
230
+ id,
231
+ {
232
+ id,
233
+ name,
234
+ hook,
235
+ }
236
+ );
237
+
238
+ return () => {
239
+ shutdownHooks.delete(
240
+ id
241
+ );
242
+ };
243
+ }
244
+
245
+ export async function runShutdownHooks(): Promise<void> {
246
+ const registered =
247
+ Array.from(
248
+ shutdownHooks.values()
249
+ ).reverse();
250
+ const failures:
251
+ Error[] = [];
252
+
253
+ shutdownHooks.clear();
254
+
255
+ for (const item of registered) {
256
+ try {
257
+ await item.hook();
258
+ } catch (error) {
259
+ failures.push(
260
+ new Error(
261
+ `BCP Framework: shutdown hook "${item.name}" failed. ${error instanceof Error ? error.message : String(error)}`
262
+ )
263
+ );
264
+ }
265
+ }
266
+
267
+ if (
268
+ failures.length > 0
269
+ ) {
270
+ throw new AggregateError(
271
+ failures,
272
+ "BCP Framework: one or more shutdown hooks failed."
273
+ );
274
+ }
275
+ }
276
+
277
+ export function isTrustedProxyEnabled(
278
+ environment: NodeJS.ProcessEnv = process.env
279
+ ): boolean {
280
+ return getProductionHardeningConfig(
281
+ environment
282
+ ).trustProxy;
283
+ }
284
+
285
+ function normalizeHookName(
286
+ value: string | undefined,
287
+ id: number
288
+ ): string {
289
+ const normalized =
290
+ value?.trim();
291
+
292
+ if (!normalized) {
293
+ return `hook-${id}`;
294
+ }
295
+
296
+ if (
297
+ normalized.length > 128 ||
298
+ /[\r\n]/.test(
299
+ normalized
300
+ )
301
+ ) {
302
+ throw new TypeError(
303
+ "BCP Framework: shutdown hook name must be at most 128 characters without line breaks."
304
+ );
305
+ }
306
+
307
+ return normalized;
308
+ }
309
+
310
+ function parsePositiveInteger(
311
+ value: string | undefined,
312
+ name: string
313
+ ): number | undefined {
314
+ if (value === undefined) {
315
+ return undefined;
316
+ }
317
+
318
+ const normalized =
319
+ value.trim();
320
+ const parsed =
321
+ Number(
322
+ normalized
323
+ );
324
+
325
+ if (
326
+ normalized.length === 0 ||
327
+ !Number.isSafeInteger(
328
+ parsed
329
+ ) ||
330
+ parsed <= 0
331
+ ) {
332
+ throw new Error(
333
+ `BCP Framework: ${name} must be a positive safe integer.`
334
+ );
335
+ }
336
+
337
+ return parsed;
338
+ }
339
+
340
+ function parseBoolean(
341
+ value: string | undefined,
342
+ name: string
343
+ ): boolean | undefined {
344
+ if (value === undefined) {
345
+ return undefined;
346
+ }
347
+
348
+ const normalized =
349
+ value
350
+ .trim()
351
+ .toLowerCase();
352
+
353
+ if (
354
+ normalized === "1" ||
355
+ normalized === "true" ||
356
+ normalized === "yes" ||
357
+ normalized === "on"
358
+ ) {
359
+ return true;
360
+ }
361
+
362
+ if (
363
+ normalized === "0" ||
364
+ normalized === "false" ||
365
+ normalized === "no" ||
366
+ normalized === "off"
367
+ ) {
368
+ return false;
369
+ }
370
+
371
+ throw new Error(
372
+ `BCP Framework: ${name} must be one of 1, 0, true, false, yes, no, on or off.`
373
+ );
374
+ }
@@ -0,0 +1,299 @@
1
+ import * as net from "node:net";
2
+
3
+ import {
4
+ createHardeningProxy,
5
+ } from "./hardening-proxy.js";
6
+ import {
7
+ getProductionHardeningConfig,
8
+ runShutdownHooks,
9
+ } from "./production-hardening.js";
10
+ import {
11
+ createStandaloneProductionServer as createBaseStandaloneProductionServer,
12
+ type StandaloneApiRoute,
13
+ type StandalonePageRoute,
14
+ type StandaloneProductionServerOptions as BaseStandaloneProductionServerOptions,
15
+ } from "./standalone-production-runtime-v6.js";
16
+
17
+ export type {
18
+ StandaloneApiRoute,
19
+ StandalonePageRoute,
20
+ };
21
+
22
+ export interface StandaloneProductionServerOptions
23
+ extends BaseStandaloneProductionServerOptions {}
24
+
25
+ export function createStandaloneProductionServer(
26
+ options: StandaloneProductionServerOptions
27
+ ) {
28
+ let baseServer:
29
+ ReturnType<
30
+ typeof createBaseStandaloneProductionServer
31
+ > | null =
32
+ null;
33
+ let hardeningProxy:
34
+ ReturnType<
35
+ typeof createHardeningProxy
36
+ > | null =
37
+ null;
38
+ let started =
39
+ false;
40
+ let stopping =
41
+ false;
42
+
43
+ const handleSigterm =
44
+ () => {
45
+ void shutdownFromSignal(
46
+ "SIGTERM"
47
+ );
48
+ };
49
+ const handleSigint =
50
+ () => {
51
+ void shutdownFromSignal(
52
+ "SIGINT"
53
+ );
54
+ };
55
+
56
+ async function start(): Promise<void> {
57
+ if (started) {
58
+ return;
59
+ }
60
+
61
+ const config =
62
+ getProductionHardeningConfig();
63
+ const internalPort =
64
+ await findFreePort();
65
+
66
+ baseServer =
67
+ createBaseStandaloneProductionServer({
68
+ ...options,
69
+ port:
70
+ internalPort,
71
+ hostname:
72
+ "127.0.0.1",
73
+ });
74
+
75
+ await baseServer.start();
76
+
77
+ try {
78
+ hardeningProxy =
79
+ createHardeningProxy({
80
+ port:
81
+ options.port,
82
+ hostname:
83
+ options.hostname,
84
+ upstreamPort:
85
+ internalPort,
86
+ upstreamHostname:
87
+ "127.0.0.1",
88
+ });
89
+ await hardeningProxy.start();
90
+ } catch (error) {
91
+ await baseServer.stop();
92
+ baseServer =
93
+ null;
94
+ hardeningProxy =
95
+ null;
96
+ throw error;
97
+ }
98
+
99
+ process.once(
100
+ "SIGTERM",
101
+ handleSigterm
102
+ );
103
+ process.once(
104
+ "SIGINT",
105
+ handleSigint
106
+ );
107
+
108
+ started =
109
+ true;
110
+
111
+ console.log(
112
+ `[BCP Runtime] Hardening gateway: http://${options.hostname}:${options.port}`
113
+ );
114
+ console.log(
115
+ `[BCP Runtime] Request timeout: ${config.requestTimeoutMs}ms | Shutdown timeout: ${config.shutdownTimeoutMs}ms | Trust proxy: ${config.trustProxy ? "on" : "off"}`
116
+ );
117
+ console.log("");
118
+ }
119
+
120
+ async function stop(): Promise<void> {
121
+ if (stopping) {
122
+ return;
123
+ }
124
+
125
+ stopping =
126
+ true;
127
+
128
+ process.off(
129
+ "SIGTERM",
130
+ handleSigterm
131
+ );
132
+ process.off(
133
+ "SIGINT",
134
+ handleSigint
135
+ );
136
+
137
+ const failures:
138
+ unknown[] = [];
139
+
140
+ try {
141
+ if (hardeningProxy) {
142
+ await hardeningProxy.stop();
143
+ hardeningProxy =
144
+ null;
145
+ }
146
+ } catch (error) {
147
+ failures.push(
148
+ error
149
+ );
150
+ }
151
+
152
+ try {
153
+ await runShutdownHooks();
154
+ } catch (error) {
155
+ failures.push(
156
+ error
157
+ );
158
+ }
159
+
160
+ try {
161
+ if (baseServer) {
162
+ await baseServer.stop();
163
+ baseServer =
164
+ null;
165
+ }
166
+ } catch (error) {
167
+ failures.push(
168
+ error
169
+ );
170
+ }
171
+
172
+ started =
173
+ false;
174
+ stopping =
175
+ false;
176
+
177
+ if (
178
+ failures.length > 0
179
+ ) {
180
+ throw new AggregateError(
181
+ failures,
182
+ "BCP Framework: production shutdown completed with errors."
183
+ );
184
+ }
185
+ }
186
+
187
+ async function shutdownFromSignal(
188
+ signal: NodeJS.Signals
189
+ ): Promise<void> {
190
+ if (stopping) {
191
+ return;
192
+ }
193
+
194
+ console.log(
195
+ `[BCP Runtime] ${signal} received. Starting graceful shutdown...`
196
+ );
197
+
198
+ try {
199
+ await stop();
200
+ console.log(
201
+ "[BCP Runtime] Graceful shutdown complete."
202
+ );
203
+ } catch (error) {
204
+ process.exitCode =
205
+ 1;
206
+ console.error(
207
+ "[BCP Runtime] Graceful shutdown failed:",
208
+ error
209
+ );
210
+ }
211
+ }
212
+
213
+ return {
214
+ start,
215
+ stop,
216
+ };
217
+ }
218
+
219
+ async function findFreePort(): Promise<number> {
220
+ const server =
221
+ net.createServer();
222
+
223
+ try {
224
+ await new Promise<void>(
225
+ (
226
+ resolve,
227
+ reject
228
+ ) => {
229
+ const onError =
230
+ (error: Error) => {
231
+ server.off(
232
+ "listening",
233
+ onListening
234
+ );
235
+ reject(
236
+ error
237
+ );
238
+ };
239
+ const onListening =
240
+ () => {
241
+ server.off(
242
+ "error",
243
+ onError
244
+ );
245
+ resolve();
246
+ };
247
+
248
+ server.once(
249
+ "error",
250
+ onError
251
+ );
252
+ server.once(
253
+ "listening",
254
+ onListening
255
+ );
256
+ server.listen(
257
+ 0,
258
+ "127.0.0.1"
259
+ );
260
+ }
261
+ );
262
+
263
+ const address =
264
+ server.address();
265
+
266
+ if (
267
+ !address ||
268
+ typeof address ===
269
+ "string"
270
+ ) {
271
+ throw new Error(
272
+ "BCP Framework: could not allocate production hardening port."
273
+ );
274
+ }
275
+
276
+ return address.port;
277
+ } finally {
278
+ if (server.listening) {
279
+ await new Promise<void>(
280
+ (
281
+ resolve,
282
+ reject
283
+ ) => {
284
+ server.close(
285
+ (error) => {
286
+ if (error) {
287
+ reject(
288
+ error
289
+ );
290
+ return;
291
+ }
292
+ resolve();
293
+ }
294
+ );
295
+ }
296
+ );
297
+ }
298
+ }
299
+ }
@@ -3,4 +3,4 @@ export {
3
3
  type StandaloneApiRoute,
4
4
  type StandalonePageRoute,
5
5
  type StandaloneProductionServerOptions,
6
- } from "./standalone-production-runtime-v6.js";
6
+ } from "./standalone-production-runtime-v7.js";