@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.
- package/README.md +49 -79
- package/docs/README.md +55 -80
- package/docs/production-hardening.md +152 -0
- package/docs/releases/0.1.28.md +134 -0
- package/package.json +1 -1
- package/packages/client/src/server.ts +9 -0
- package/packages/server/src/hardening-proxy.ts +393 -0
- package/packages/server/src/production-hardening.ts +374 -0
- package/packages/server/src/standalone-production-runtime-v7.ts +299 -0
- package/packages/server/src/standalone-production-server.ts +1 -1
|
@@ -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.
|
|
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
|
+
}
|