@chidchanun/bcp 0.1.27 → 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,134 @@
1
+ # BCP Framework 0.1.28
2
+
3
+ BCP Framework `0.1.28` is the **Production Hardening** milestone.
4
+
5
+ > Release state: unreleased development target until local validation, RC checks, tagging and npm publication complete.
6
+
7
+ ## Highlights
8
+
9
+ - Added a public production hardening gateway around the existing standalone runtime.
10
+ - Added configurable request, header, keep-alive and shutdown timeouts.
11
+ - Added graceful `SIGTERM` / `SIGINT` handling for Docker and process managers.
12
+ - Added application shutdown hooks through `registerShutdownHook()`.
13
+ - Added trusted-proxy forwarding-header sanitization with secure default `BCP_TRUST_PROXY=false`.
14
+ - Added force-close fallback after the configured graceful shutdown timeout.
15
+ - Fixed `create-bcp-app --storage local` so generated projects no longer contain an unexplained empty storage directory.
16
+ - Added `storage/README.md` and `storage/.gitkeep` while continuing to ignore runtime storage objects.
17
+ - Added regression coverage for production hardening primitives and Local Server scaffolding.
18
+
19
+ ## Production environment controls
20
+
21
+ ```dotenv
22
+ BCP_REQUEST_TIMEOUT_MS=120000
23
+ BCP_HEADERS_TIMEOUT_MS=66000
24
+ BCP_KEEP_ALIVE_TIMEOUT_MS=65000
25
+ BCP_SHUTDOWN_TIMEOUT_MS=10000
26
+ BCP_TRUST_PROXY=false
27
+ ```
28
+
29
+ Defaults are production-safe and require no configuration for ordinary direct HTTP deployments.
30
+
31
+ `BCP_HEADERS_TIMEOUT_MS` must be greater than `BCP_KEEP_ALIVE_TIMEOUT_MS`.
32
+
33
+ ## Graceful shutdown
34
+
35
+ The standalone production runtime now handles:
36
+
37
+ ```text
38
+ SIGTERM
39
+ SIGINT
40
+ ```
41
+
42
+ The runtime first stops/drains the public hardening gateway, then runs application shutdown hooks, then stops internal BCP runtime layers.
43
+
44
+ After `BCP_SHUTDOWN_TIMEOUT_MS`, remaining public connections are force-closed.
45
+
46
+ ## Public API addition
47
+
48
+ ```ts
49
+ import {
50
+ getProductionHardeningConfig,
51
+ registerShutdownHook,
52
+ } from "bcp/server";
53
+ ```
54
+
55
+ Example:
56
+
57
+ ```ts
58
+ registerShutdownHook(
59
+ () => {
60
+ storage.destroy();
61
+ },
62
+ {
63
+ name: "storage",
64
+ }
65
+ );
66
+ ```
67
+
68
+ Hooks run in reverse registration order and can be unregistered with the cleanup function returned by `registerShutdownHook()`.
69
+
70
+ ## Trusted proxy behavior
71
+
72
+ Trusted proxy mode is disabled by default.
73
+
74
+ When disabled, BCP strips spoofable incoming forwarding headers at the public hardening gateway and writes forwarding information from the actual connection.
75
+
76
+ When the application is intentionally deployed behind a trusted reverse proxy/load balancer, enable:
77
+
78
+ ```dotenv
79
+ BCP_TRUST_PROXY=true
80
+ ```
81
+
82
+ Only enable it when direct untrusted traffic cannot bypass the trusted proxy.
83
+
84
+ ## Local Server storage fix
85
+
86
+ Before `0.1.28`, selecting Local Server generated `lib/storage.ts` and configured `./storage`, but the runtime directory had no explanatory scaffold before the first upload.
87
+
88
+ `0.1.28` generates:
89
+
90
+ ```text
91
+ storage/
92
+ ├─ .gitkeep
93
+ └─ README.md
94
+ ```
95
+
96
+ and uses Git ignore rules:
97
+
98
+ ```gitignore
99
+ storage/*
100
+ !storage/.gitkeep
101
+ !storage/README.md
102
+ ```
103
+
104
+ Runtime uploads and `.bcp-storage-meta` remain untracked while the directory structure is visible in a fresh project.
105
+
106
+ ## Testing
107
+
108
+ `0.1.28` adds coverage for:
109
+
110
+ - production timeout parsing,
111
+ - invalid timeout relationships,
112
+ - applying Node HTTP server timeout settings,
113
+ - graceful HTTP server close,
114
+ - shutdown hook ordering/unregistration,
115
+ - generated Local Server storage directory scaffold,
116
+ - generated Local Server Git ignore behavior.
117
+
118
+ ## Remaining release work
119
+
120
+ Before publication:
121
+
122
+ 1. sync `package-lock.json` to `0.1.28`,
123
+ 2. run `npm run typecheck`,
124
+ 3. run unit/integration/E2E/package tests,
125
+ 4. run `npm run rc:check`,
126
+ 5. build a representative BCP application,
127
+ 6. run the standalone artifact in Docker,
128
+ 7. verify `docker stop` produces graceful shutdown logs,
129
+ 8. create the `v0.1.28` tag only after the final release commit is known,
130
+ 9. publish and verify npm visibility.
131
+
132
+ ## Next milestone
133
+
134
+ The planned next milestone is `0.1.29 — Developer Experience`, focused on generators, richer `doctor` / `inspect`, improved diagnostics and create-app workflow improvements.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,15 @@ export {
30
30
  type LoggerOptions,
31
31
  } from "../../server/src/logger.js";
32
32
 
33
+ export {
34
+ getProductionHardeningConfig,
35
+ registerShutdownHook,
36
+
37
+ type ProductionHardeningConfig,
38
+ type ShutdownHook,
39
+ type ShutdownHookOptions,
40
+ } from "../../server/src/production-hardening.js";
41
+
33
42
  export {
34
43
  getUploadedFile,
35
44
  parseMultipartFormData,
@@ -0,0 +1,393 @@
1
+ import * as http from "node:http";
2
+
3
+ import {
4
+ applyProductionHttpServerHardening,
5
+ closeHttpServerGracefully,
6
+ getProductionHardeningConfig,
7
+ type ProductionHardeningConfig,
8
+ } from "./production-hardening.js";
9
+
10
+ export interface HardeningProxyOptions {
11
+ port: number;
12
+ hostname: string;
13
+ upstreamPort: number;
14
+ upstreamHostname?: string;
15
+ }
16
+
17
+ export function createHardeningProxy(
18
+ options: HardeningProxyOptions
19
+ ) {
20
+ const upstreamHostname =
21
+ options.upstreamHostname ??
22
+ "127.0.0.1";
23
+ const config =
24
+ getProductionHardeningConfig();
25
+ let server:
26
+ http.Server | null =
27
+ null;
28
+
29
+ async function start(): Promise<void> {
30
+ if (server) {
31
+ return;
32
+ }
33
+
34
+ server =
35
+ http.createServer(
36
+ (
37
+ req,
38
+ res
39
+ ) => {
40
+ proxyRequest(
41
+ req,
42
+ res,
43
+ upstreamHostname,
44
+ options.upstreamPort,
45
+ config
46
+ );
47
+ }
48
+ );
49
+
50
+ applyProductionHttpServerHardening(
51
+ server,
52
+ config
53
+ );
54
+
55
+ await listen(
56
+ server,
57
+ options.port,
58
+ options.hostname
59
+ );
60
+ }
61
+
62
+ async function stop(): Promise<void> {
63
+ if (!server) {
64
+ return;
65
+ }
66
+
67
+ const current =
68
+ server;
69
+ server =
70
+ null;
71
+
72
+ await closeHttpServerGracefully(
73
+ current,
74
+ {
75
+ timeoutMs:
76
+ config.shutdownTimeoutMs,
77
+ }
78
+ );
79
+ }
80
+
81
+ return {
82
+ start,
83
+ stop,
84
+ config,
85
+ };
86
+ }
87
+
88
+ function proxyRequest(
89
+ req: http.IncomingMessage,
90
+ res: http.ServerResponse,
91
+ upstreamHostname: string,
92
+ upstreamPort: number,
93
+ config: ProductionHardeningConfig
94
+ ): void {
95
+ const headers =
96
+ createForwardedHeaders(
97
+ req,
98
+ config.trustProxy
99
+ );
100
+ const upstream =
101
+ http.request({
102
+ hostname:
103
+ upstreamHostname,
104
+ port:
105
+ upstreamPort,
106
+ method:
107
+ req.method ??
108
+ "GET",
109
+ path:
110
+ req.url ??
111
+ "/",
112
+ headers,
113
+ timeout:
114
+ config.requestTimeoutMs,
115
+ });
116
+
117
+ upstream.on(
118
+ "timeout",
119
+ () => {
120
+ upstream.destroy(
121
+ new Error(
122
+ "BCP Framework: upstream request timed out."
123
+ )
124
+ );
125
+ }
126
+ );
127
+
128
+ upstream.on(
129
+ "response",
130
+ (upstreamResponse) => {
131
+ for (
132
+ const [
133
+ name,
134
+ value,
135
+ ]
136
+ of Object.entries(
137
+ upstreamResponse.headers
138
+ )
139
+ ) {
140
+ if (
141
+ value !== undefined
142
+ ) {
143
+ res.setHeader(
144
+ name,
145
+ value
146
+ );
147
+ }
148
+ }
149
+
150
+ res.statusCode =
151
+ upstreamResponse.statusCode ??
152
+ 502;
153
+
154
+ if (
155
+ upstreamResponse.statusMessage
156
+ ) {
157
+ res.statusMessage =
158
+ upstreamResponse.statusMessage;
159
+ }
160
+
161
+ upstreamResponse.pipe(
162
+ res
163
+ );
164
+ }
165
+ );
166
+
167
+ upstream.on(
168
+ "error",
169
+ (error) => {
170
+ if (
171
+ res.headersSent
172
+ ) {
173
+ res.destroy(
174
+ error
175
+ );
176
+ return;
177
+ }
178
+
179
+ res.writeHead(
180
+ 502,
181
+ {
182
+ "Content-Type":
183
+ "text/plain; charset=utf-8",
184
+ "Cache-Control":
185
+ "no-store",
186
+ }
187
+ );
188
+ res.end(
189
+ "Bad Gateway"
190
+ );
191
+ }
192
+ );
193
+
194
+ req.on(
195
+ "aborted",
196
+ () => {
197
+ upstream.destroy();
198
+ }
199
+ );
200
+ req.on(
201
+ "error",
202
+ (error) => {
203
+ upstream.destroy(
204
+ error
205
+ );
206
+ }
207
+ );
208
+
209
+ req.pipe(
210
+ upstream
211
+ );
212
+ }
213
+
214
+ function createForwardedHeaders(
215
+ req: http.IncomingMessage,
216
+ trustProxy: boolean
217
+ ): http.OutgoingHttpHeaders {
218
+ const headers:
219
+ http.OutgoingHttpHeaders = {
220
+ ...req.headers,
221
+ };
222
+ const remoteAddress =
223
+ normalizeRemoteAddress(
224
+ req.socket.remoteAddress
225
+ );
226
+ const host =
227
+ firstHeader(
228
+ req.headers.host
229
+ );
230
+
231
+ if (!trustProxy) {
232
+ delete headers.forwarded;
233
+ delete headers[
234
+ "x-forwarded-for"
235
+ ];
236
+ delete headers[
237
+ "x-forwarded-host"
238
+ ];
239
+ delete headers[
240
+ "x-forwarded-proto"
241
+ ];
242
+ delete headers[
243
+ "x-real-ip"
244
+ ];
245
+ }
246
+
247
+ if (remoteAddress) {
248
+ const existingForwardedFor =
249
+ trustProxy
250
+ ? firstHeader(
251
+ req.headers[
252
+ "x-forwarded-for"
253
+ ]
254
+ )
255
+ : undefined;
256
+
257
+ headers[
258
+ "x-forwarded-for"
259
+ ] =
260
+ existingForwardedFor
261
+ ? `${existingForwardedFor}, ${remoteAddress}`
262
+ : remoteAddress;
263
+ headers[
264
+ "x-real-ip"
265
+ ] =
266
+ trustProxy &&
267
+ firstHeader(
268
+ req.headers[
269
+ "x-real-ip"
270
+ ]
271
+ )
272
+ ? firstHeader(
273
+ req.headers[
274
+ "x-real-ip"
275
+ ]
276
+ )
277
+ : remoteAddress;
278
+ }
279
+
280
+ if (host) {
281
+ headers[
282
+ "x-forwarded-host"
283
+ ] =
284
+ trustProxy &&
285
+ firstHeader(
286
+ req.headers[
287
+ "x-forwarded-host"
288
+ ]
289
+ )
290
+ ? firstHeader(
291
+ req.headers[
292
+ "x-forwarded-host"
293
+ ]
294
+ )
295
+ : host;
296
+ }
297
+
298
+ headers[
299
+ "x-forwarded-proto"
300
+ ] =
301
+ trustProxy &&
302
+ firstHeader(
303
+ req.headers[
304
+ "x-forwarded-proto"
305
+ ]
306
+ )
307
+ ? firstHeader(
308
+ req.headers[
309
+ "x-forwarded-proto"
310
+ ]
311
+ )
312
+ : "http";
313
+
314
+ return headers;
315
+ }
316
+
317
+ function normalizeRemoteAddress(
318
+ value: string | undefined
319
+ ): string | undefined {
320
+ if (!value) {
321
+ return undefined;
322
+ }
323
+
324
+ return value.startsWith(
325
+ "::ffff:"
326
+ )
327
+ ? value.slice(
328
+ "::ffff:".length
329
+ )
330
+ : value;
331
+ }
332
+
333
+ function firstHeader(
334
+ value:
335
+ string |
336
+ string[] |
337
+ undefined
338
+ ): string | undefined {
339
+ if (
340
+ Array.isArray(
341
+ value
342
+ )
343
+ ) {
344
+ return value[0];
345
+ }
346
+
347
+ return value;
348
+ }
349
+
350
+ function listen(
351
+ server: http.Server,
352
+ port: number,
353
+ hostname: string
354
+ ): Promise<void> {
355
+ return new Promise(
356
+ (
357
+ resolve,
358
+ reject
359
+ ) => {
360
+ const onError =
361
+ (error: Error) => {
362
+ server.off(
363
+ "listening",
364
+ onListening
365
+ );
366
+ reject(
367
+ error
368
+ );
369
+ };
370
+ const onListening =
371
+ () => {
372
+ server.off(
373
+ "error",
374
+ onError
375
+ );
376
+ resolve();
377
+ };
378
+
379
+ server.once(
380
+ "error",
381
+ onError
382
+ );
383
+ server.once(
384
+ "listening",
385
+ onListening
386
+ );
387
+ server.listen(
388
+ port,
389
+ hostname
390
+ );
391
+ }
392
+ );
393
+ }