@chidchanun/bcp 0.1.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/docs/caching.md +76 -0
  5. package/docs/configuration.md +97 -0
  6. package/docs/deployment.md +74 -0
  7. package/docs/getting-started.md +82 -0
  8. package/docs/middleware.md +58 -0
  9. package/docs/releasing.md +299 -0
  10. package/docs/routing.md +103 -0
  11. package/docs/security.md +57 -0
  12. package/package.json +68 -0
  13. package/packages/bundler/src/client-islands.ts +1457 -0
  14. package/packages/bundler/src/incremental-context.ts +206 -0
  15. package/packages/bundler/src/index.ts +1991 -0
  16. package/packages/bundler/src/module-graph.ts +317 -0
  17. package/packages/bundler/src/partial-hydration.ts +414 -0
  18. package/packages/bundler/src/production.ts +974 -0
  19. package/packages/bundler/src/server-production-middleware.ts +447 -0
  20. package/packages/bundler/src/server-production.ts +1193 -0
  21. package/packages/bundler/src/special-files.ts +131 -0
  22. package/packages/cache/src/index.ts +761 -0
  23. package/packages/cli/bin/bcp.mjs +93 -0
  24. package/packages/cli/src/args.ts +305 -0
  25. package/packages/cli/src/bootstrap.ts +514 -0
  26. package/packages/cli/src/index.ts +504 -0
  27. package/packages/cli/src/version.ts +45 -0
  28. package/packages/client/src/cache.ts +11 -0
  29. package/packages/client/src/config.ts +18 -0
  30. package/packages/client/src/error-boundary.tsx +149 -0
  31. package/packages/client/src/hydration.ts +3 -0
  32. package/packages/client/src/index.tsx +57 -0
  33. package/packages/client/src/islands.tsx +315 -0
  34. package/packages/client/src/metadata.ts +281 -0
  35. package/packages/client/src/navigation-loading.ts +52 -0
  36. package/packages/client/src/navigation-state.ts +80 -0
  37. package/packages/client/src/not-found.ts +34 -0
  38. package/packages/client/src/persistent-layout-runtime.ts +273 -0
  39. package/packages/client/src/router-v2.tsx +969 -0
  40. package/packages/client/src/router.tsx +1 -0
  41. package/packages/config/src/index.ts +1038 -0
  42. package/packages/env/src/index.ts +593 -0
  43. package/packages/router/src/advanced-router.ts +1032 -0
  44. package/packages/router/src/index.ts +1 -0
  45. package/packages/server/src/compression.ts +249 -0
  46. package/packages/server/src/dev-document-metadata.ts +154 -0
  47. package/packages/server/src/dev-hmr.ts +225 -0
  48. package/packages/server/src/index.ts +2265 -0
  49. package/packages/server/src/metadata.ts +478 -0
  50. package/packages/server/src/middleware-dev-server.ts +260 -0
  51. package/packages/server/src/middleware-loader.ts +140 -0
  52. package/packages/server/src/middleware-proxy.ts +516 -0
  53. package/packages/server/src/middleware.ts +704 -0
  54. package/packages/server/src/navigation-payload.ts +471 -0
  55. package/packages/server/src/production-server.ts +1746 -0
  56. package/packages/server/src/response-cache-proxy.ts +828 -0
  57. package/packages/server/src/security-proxy.ts +406 -0
  58. package/packages/server/src/security.ts +451 -0
  59. package/packages/server/src/standalone-production-runtime-v2.ts +2047 -0
  60. package/packages/server/src/standalone-production-runtime-v3.ts +250 -0
  61. package/packages/server/src/standalone-production-runtime-v4.ts +289 -0
  62. package/packages/server/src/standalone-production-runtime-v5.ts +289 -0
  63. package/packages/server/src/standalone-production-runtime.ts +1951 -0
  64. package/packages/server/src/standalone-production-server.ts +6 -0
  65. package/packages/server/src/static-assets.ts +262 -0
  66. package/packages/server/src/static-dev-server.ts +854 -0
@@ -0,0 +1,1038 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ pathToFileURL,
5
+ } from "node:url";
6
+
7
+ export interface BcpServerConfig {
8
+ port?: number;
9
+ hostname?: string;
10
+ bodyLimit?: number;
11
+ }
12
+
13
+ export interface BcpBuildConfig {
14
+ minify?: boolean;
15
+ sourceMaps?: boolean;
16
+ }
17
+
18
+ export interface BcpCacheConfig {
19
+ response?: boolean;
20
+ }
21
+
22
+ export interface BcpExperimentalConfig {
23
+ partialHydration?: boolean;
24
+ islands?: boolean;
25
+ }
26
+
27
+ export interface BcpSecurityConfig {
28
+ poweredByHeader?: boolean;
29
+ contentSecurityPolicy?: string | false;
30
+ frameOptions?: "DENY" | "SAMEORIGIN" | false;
31
+ referrerPolicy?: string;
32
+ permissionsPolicy?: string | false;
33
+ }
34
+
35
+ export interface BcpConfig {
36
+ server?: BcpServerConfig;
37
+ compression?: boolean;
38
+ build?: BcpBuildConfig;
39
+ cache?: BcpCacheConfig;
40
+ experimental?: BcpExperimentalConfig;
41
+ security?: BcpSecurityConfig;
42
+ }
43
+
44
+ export interface ResolvedBcpConfig {
45
+ server: {
46
+ port: number;
47
+ hostname: string;
48
+ bodyLimit: number;
49
+ };
50
+ compression: boolean;
51
+ build: {
52
+ minify: boolean;
53
+ sourceMaps: boolean;
54
+ };
55
+ cache: {
56
+ response: boolean;
57
+ };
58
+ experimental: {
59
+ partialHydration: boolean;
60
+ islands: boolean;
61
+ };
62
+ security: {
63
+ poweredByHeader: boolean;
64
+ contentSecurityPolicy: string | false;
65
+ frameOptions: "DENY" | "SAMEORIGIN" | false;
66
+ referrerPolicy: string;
67
+ permissionsPolicy: string | false;
68
+ };
69
+ }
70
+
71
+ export interface ResolveBcpConfigOverrides {
72
+ port?: number;
73
+ hostname?: string;
74
+ }
75
+
76
+ export interface LoadedBcpConfig {
77
+ file: string | null;
78
+ config: BcpConfig;
79
+ }
80
+
81
+ const CONFIG_FILES = [
82
+ "bcp.config.ts",
83
+ "bcp.config.mts",
84
+ "bcp.config.js",
85
+ "bcp.config.mjs",
86
+ ] as const;
87
+
88
+ export const defaultBcpConfig: ResolvedBcpConfig = {
89
+ server: {
90
+ port: 3000,
91
+ hostname: "localhost",
92
+ bodyLimit:
93
+ 1024 * 1024,
94
+ },
95
+ compression: true,
96
+ build: {
97
+ minify: true,
98
+ sourceMaps: false,
99
+ },
100
+ cache: {
101
+ response: true,
102
+ },
103
+ experimental: {
104
+ partialHydration: true,
105
+ islands: true,
106
+ },
107
+ security: {
108
+ poweredByHeader: false,
109
+ contentSecurityPolicy: false,
110
+ frameOptions:
111
+ "SAMEORIGIN",
112
+ referrerPolicy:
113
+ "strict-origin-when-cross-origin",
114
+ permissionsPolicy:
115
+ "camera=(), microphone=(), geolocation=()",
116
+ },
117
+ };
118
+
119
+ export function defineConfig<
120
+ T extends BcpConfig
121
+ >(
122
+ config: T
123
+ ): T {
124
+ return config;
125
+ }
126
+
127
+ export function getConfigFileNames(): string[] {
128
+ return [
129
+ ...CONFIG_FILES,
130
+ ];
131
+ }
132
+
133
+ export async function loadBcpConfig(
134
+ rootDirectory: string
135
+ ): Promise<LoadedBcpConfig> {
136
+ const matches =
137
+ CONFIG_FILES
138
+ .map(
139
+ (fileName) =>
140
+ path.join(
141
+ rootDirectory,
142
+ fileName
143
+ )
144
+ )
145
+ .filter(
146
+ (filePath) =>
147
+ fs.existsSync(
148
+ filePath
149
+ )
150
+ );
151
+
152
+ if (
153
+ matches.length > 1
154
+ ) {
155
+ throw new Error(
156
+ `BCP Framework: multiple config files found: ${matches.map((file) => path.basename(file)).join(", ")}. Keep only one bcp.config file.`
157
+ );
158
+ }
159
+
160
+ if (
161
+ matches.length === 0
162
+ ) {
163
+ return {
164
+ file: null,
165
+ config: {},
166
+ };
167
+ }
168
+
169
+ const file =
170
+ matches[0];
171
+ const url =
172
+ pathToFileURL(
173
+ file
174
+ );
175
+
176
+ url.searchParams.set(
177
+ "bcp-config",
178
+ `${Date.now()}-${Math.random()}`
179
+ );
180
+
181
+ const module =
182
+ await import(
183
+ url.href
184
+ );
185
+
186
+ const config =
187
+ module.default;
188
+
189
+ if (
190
+ config === undefined
191
+ ) {
192
+ throw new Error(
193
+ `BCP Framework: ${path.basename(file)} must export a default config object.`
194
+ );
195
+ }
196
+
197
+ assertConfig(
198
+ config,
199
+ path.basename(file)
200
+ );
201
+
202
+ return {
203
+ file,
204
+ config,
205
+ };
206
+ }
207
+
208
+ export async function resolveBcpConfig(
209
+ rootDirectory: string,
210
+ overrides: ResolveBcpConfigOverrides = {},
211
+ environment: NodeJS.ProcessEnv = process.env
212
+ ): Promise<{
213
+ file: string | null;
214
+ config: ResolvedBcpConfig;
215
+ }> {
216
+ const loaded =
217
+ await loadBcpConfig(
218
+ rootDirectory
219
+ );
220
+
221
+ const config =
222
+ loaded.config;
223
+
224
+ const resolved:
225
+ ResolvedBcpConfig = {
226
+ server: {
227
+ port:
228
+ overrides.port ??
229
+ parseEnvironmentPort(
230
+ environment.BCP_PORT
231
+ ) ??
232
+ config.server?.port ??
233
+ defaultBcpConfig.server.port,
234
+ hostname:
235
+ overrides.hostname ??
236
+ nonEmptyEnvironmentValue(
237
+ environment.BCP_HOSTNAME
238
+ ) ??
239
+ config.server?.hostname ??
240
+ defaultBcpConfig.server.hostname,
241
+ bodyLimit:
242
+ parsePositiveInteger(
243
+ environment.BCP_BODY_LIMIT,
244
+ "BCP_BODY_LIMIT"
245
+ ) ??
246
+ config.server?.bodyLimit ??
247
+ defaultBcpConfig.server.bodyLimit,
248
+ },
249
+ compression:
250
+ parseEnvironmentBoolean(
251
+ environment.BCP_COMPRESSION,
252
+ "BCP_COMPRESSION"
253
+ ) ??
254
+ config.compression ??
255
+ defaultBcpConfig.compression,
256
+ build: {
257
+ minify:
258
+ parseEnvironmentBoolean(
259
+ environment.BCP_BUILD_MINIFY,
260
+ "BCP_BUILD_MINIFY"
261
+ ) ??
262
+ config.build?.minify ??
263
+ defaultBcpConfig.build.minify,
264
+ sourceMaps:
265
+ parseEnvironmentBoolean(
266
+ environment.BCP_BUILD_SOURCE_MAPS,
267
+ "BCP_BUILD_SOURCE_MAPS"
268
+ ) ??
269
+ config.build?.sourceMaps ??
270
+ defaultBcpConfig.build.sourceMaps,
271
+ },
272
+ cache: {
273
+ response:
274
+ parseEnvironmentBoolean(
275
+ environment.BCP_RESPONSE_CACHE,
276
+ "BCP_RESPONSE_CACHE"
277
+ ) ??
278
+ config.cache?.response ??
279
+ defaultBcpConfig.cache.response,
280
+ },
281
+ experimental: {
282
+ partialHydration:
283
+ parseEnvironmentBoolean(
284
+ environment.BCP_EXPERIMENTAL_PARTIAL_HYDRATION,
285
+ "BCP_EXPERIMENTAL_PARTIAL_HYDRATION"
286
+ ) ??
287
+ config.experimental?.partialHydration ??
288
+ defaultBcpConfig.experimental.partialHydration,
289
+ islands:
290
+ parseEnvironmentBoolean(
291
+ environment.BCP_EXPERIMENTAL_ISLANDS,
292
+ "BCP_EXPERIMENTAL_ISLANDS"
293
+ ) ??
294
+ config.experimental?.islands ??
295
+ defaultBcpConfig.experimental.islands,
296
+ },
297
+ security: {
298
+ poweredByHeader:
299
+ parseEnvironmentBoolean(
300
+ environment.BCP_POWERED_BY_HEADER,
301
+ "BCP_POWERED_BY_HEADER"
302
+ ) ??
303
+ config.security?.poweredByHeader ??
304
+ defaultBcpConfig.security.poweredByHeader,
305
+ contentSecurityPolicy:
306
+ parseOptionalHeaderEnvironment(
307
+ environment.BCP_SECURITY_CSP,
308
+ "BCP_SECURITY_CSP"
309
+ ) ??
310
+ config.security?.contentSecurityPolicy ??
311
+ defaultBcpConfig.security.contentSecurityPolicy,
312
+ frameOptions:
313
+ parseFrameOptionsEnvironment(
314
+ environment.BCP_SECURITY_FRAME_OPTIONS
315
+ ) ??
316
+ config.security?.frameOptions ??
317
+ defaultBcpConfig.security.frameOptions,
318
+ referrerPolicy:
319
+ nonEmptyEnvironmentValue(
320
+ environment.BCP_SECURITY_REFERRER_POLICY
321
+ ) ??
322
+ config.security?.referrerPolicy ??
323
+ defaultBcpConfig.security.referrerPolicy,
324
+ permissionsPolicy:
325
+ parseOptionalHeaderEnvironment(
326
+ environment.BCP_SECURITY_PERMISSIONS_POLICY,
327
+ "BCP_SECURITY_PERMISSIONS_POLICY"
328
+ ) ??
329
+ config.security?.permissionsPolicy ??
330
+ defaultBcpConfig.security.permissionsPolicy,
331
+ },
332
+ };
333
+
334
+ validateResolvedConfig(
335
+ resolved
336
+ );
337
+
338
+ return {
339
+ file:
340
+ loaded.file,
341
+ config:
342
+ resolved,
343
+ };
344
+ }
345
+
346
+ export function applyResolvedBcpConfig(
347
+ config: ResolvedBcpConfig
348
+ ): void {
349
+ process.env.BCP_RESOLVED_CONFIG =
350
+ JSON.stringify(
351
+ config
352
+ );
353
+ process.env.BCP_COMPRESSION =
354
+ config.compression
355
+ ? "1"
356
+ : "0";
357
+ process.env.BCP_BODY_LIMIT =
358
+ String(
359
+ config.server.bodyLimit
360
+ );
361
+ process.env.BCP_BUILD_MINIFY =
362
+ config.build.minify
363
+ ? "1"
364
+ : "0";
365
+ process.env.BCP_BUILD_SOURCE_MAPS =
366
+ config.build.sourceMaps
367
+ ? "1"
368
+ : "0";
369
+ process.env.BCP_RESPONSE_CACHE =
370
+ config.cache.response
371
+ ? "1"
372
+ : "0";
373
+ process.env.BCP_EXPERIMENTAL_PARTIAL_HYDRATION =
374
+ config.experimental.partialHydration
375
+ ? "1"
376
+ : "0";
377
+ process.env.BCP_EXPERIMENTAL_ISLANDS =
378
+ config.experimental.islands
379
+ ? "1"
380
+ : "0";
381
+ process.env.BCP_POWERED_BY_HEADER =
382
+ config.security.poweredByHeader
383
+ ? "1"
384
+ : "0";
385
+ process.env.BCP_SECURITY_CSP =
386
+ config.security.contentSecurityPolicy === false
387
+ ? "0"
388
+ : config.security.contentSecurityPolicy;
389
+ process.env.BCP_SECURITY_FRAME_OPTIONS =
390
+ config.security.frameOptions === false
391
+ ? "0"
392
+ : config.security.frameOptions;
393
+ process.env.BCP_SECURITY_REFERRER_POLICY =
394
+ config.security.referrerPolicy;
395
+ process.env.BCP_SECURITY_PERMISSIONS_POLICY =
396
+ config.security.permissionsPolicy === false
397
+ ? "0"
398
+ : config.security.permissionsPolicy;
399
+ }
400
+
401
+ export function readResolvedBcpConfig(): ResolvedBcpConfig {
402
+ const serialized =
403
+ process.env.BCP_RESOLVED_CONFIG;
404
+
405
+ if (!serialized) {
406
+ return structuredClone(
407
+ defaultBcpConfig
408
+ );
409
+ }
410
+
411
+ try {
412
+ const value =
413
+ JSON.parse(
414
+ serialized
415
+ ) as ResolvedBcpConfig;
416
+
417
+ validateResolvedConfig(
418
+ value
419
+ );
420
+
421
+ return value;
422
+ } catch (error) {
423
+ throw new Error(
424
+ `BCP Framework: invalid resolved configuration state. ${error instanceof Error ? error.message : String(error)}`
425
+ );
426
+ }
427
+ }
428
+
429
+ function assertConfig(
430
+ value: unknown,
431
+ label: string
432
+ ): asserts value is BcpConfig {
433
+ if (
434
+ !isPlainObject(
435
+ value
436
+ )
437
+ ) {
438
+ throw new Error(
439
+ `BCP Framework: ${label} must export an object.`
440
+ );
441
+ }
442
+
443
+ const allowed =
444
+ new Set([
445
+ "server",
446
+ "compression",
447
+ "build",
448
+ "cache",
449
+ "experimental",
450
+ "security",
451
+ ]);
452
+
453
+ for (
454
+ const key
455
+ of Object.keys(
456
+ value
457
+ )
458
+ ) {
459
+ if (
460
+ !allowed.has(
461
+ key
462
+ )
463
+ ) {
464
+ throw new Error(
465
+ `BCP Framework: unknown config option "${key}" in ${label}.`
466
+ );
467
+ }
468
+ }
469
+
470
+ if (
471
+ value.server !== undefined
472
+ ) {
473
+ assertObjectKeys(
474
+ value.server,
475
+ "server",
476
+ [
477
+ "port",
478
+ "hostname",
479
+ "bodyLimit",
480
+ ]
481
+ );
482
+
483
+ if (
484
+ value.server.port !== undefined
485
+ ) {
486
+ assertPort(
487
+ value.server.port,
488
+ "server.port"
489
+ );
490
+ }
491
+
492
+ if (
493
+ value.server.hostname !== undefined &&
494
+ (
495
+ typeof value.server.hostname !== "string" ||
496
+ value.server.hostname.trim() === ""
497
+ )
498
+ ) {
499
+ throw new Error(
500
+ "BCP Framework: server.hostname must be a non-empty string."
501
+ );
502
+ }
503
+
504
+ if (
505
+ value.server.bodyLimit !== undefined
506
+ ) {
507
+ assertPositiveInteger(
508
+ value.server.bodyLimit,
509
+ "server.bodyLimit"
510
+ );
511
+ }
512
+ }
513
+
514
+ assertOptionalBoolean(
515
+ value.compression,
516
+ "compression"
517
+ );
518
+
519
+ if (
520
+ value.build !== undefined
521
+ ) {
522
+ assertObjectKeys(
523
+ value.build,
524
+ "build",
525
+ [
526
+ "minify",
527
+ "sourceMaps",
528
+ ]
529
+ );
530
+ assertOptionalBoolean(
531
+ value.build.minify,
532
+ "build.minify"
533
+ );
534
+ assertOptionalBoolean(
535
+ value.build.sourceMaps,
536
+ "build.sourceMaps"
537
+ );
538
+ }
539
+
540
+ if (
541
+ value.cache !== undefined
542
+ ) {
543
+ assertObjectKeys(
544
+ value.cache,
545
+ "cache",
546
+ [
547
+ "response",
548
+ ]
549
+ );
550
+ assertOptionalBoolean(
551
+ value.cache.response,
552
+ "cache.response"
553
+ );
554
+ }
555
+
556
+ if (
557
+ value.experimental !== undefined
558
+ ) {
559
+ assertObjectKeys(
560
+ value.experimental,
561
+ "experimental",
562
+ [
563
+ "partialHydration",
564
+ "islands",
565
+ ]
566
+ );
567
+ assertOptionalBoolean(
568
+ value.experimental.partialHydration,
569
+ "experimental.partialHydration"
570
+ );
571
+ assertOptionalBoolean(
572
+ value.experimental.islands,
573
+ "experimental.islands"
574
+ );
575
+ }
576
+
577
+ if (
578
+ value.security !== undefined
579
+ ) {
580
+ assertObjectKeys(
581
+ value.security,
582
+ "security",
583
+ [
584
+ "poweredByHeader",
585
+ "contentSecurityPolicy",
586
+ "frameOptions",
587
+ "referrerPolicy",
588
+ "permissionsPolicy",
589
+ ]
590
+ );
591
+ assertOptionalBoolean(
592
+ value.security.poweredByHeader,
593
+ "security.poweredByHeader"
594
+ );
595
+ assertOptionalHeaderOrFalse(
596
+ value.security.contentSecurityPolicy,
597
+ "security.contentSecurityPolicy"
598
+ );
599
+ assertFrameOptions(
600
+ value.security.frameOptions,
601
+ "security.frameOptions"
602
+ );
603
+ assertOptionalHeaderString(
604
+ value.security.referrerPolicy,
605
+ "security.referrerPolicy"
606
+ );
607
+ assertOptionalHeaderOrFalse(
608
+ value.security.permissionsPolicy,
609
+ "security.permissionsPolicy"
610
+ );
611
+ }
612
+ }
613
+
614
+ function validateResolvedConfig(
615
+ value: ResolvedBcpConfig
616
+ ): void {
617
+ if (
618
+ !value ||
619
+ !value.server ||
620
+ !value.build ||
621
+ !value.cache ||
622
+ !value.experimental ||
623
+ !value.security
624
+ ) {
625
+ throw new Error(
626
+ "Resolved config is incomplete."
627
+ );
628
+ }
629
+
630
+ assertPort(
631
+ value.server.port,
632
+ "server.port"
633
+ );
634
+ assertPositiveInteger(
635
+ value.server.bodyLimit,
636
+ "server.bodyLimit"
637
+ );
638
+
639
+ if (
640
+ typeof value.server.hostname !== "string" ||
641
+ value.server.hostname.trim() === ""
642
+ ) {
643
+ throw new Error(
644
+ "server.hostname must be a non-empty string."
645
+ );
646
+ }
647
+
648
+ for (
649
+ const [
650
+ label,
651
+ item,
652
+ ]
653
+ of [
654
+ ["compression", value.compression],
655
+ ["build.minify", value.build.minify],
656
+ ["build.sourceMaps", value.build.sourceMaps],
657
+ ["cache.response", value.cache.response],
658
+ ["experimental.partialHydration", value.experimental.partialHydration],
659
+ ["experimental.islands", value.experimental.islands],
660
+ ["security.poweredByHeader", value.security.poweredByHeader],
661
+ ] as const
662
+ ) {
663
+ if (
664
+ typeof item !== "boolean"
665
+ ) {
666
+ throw new Error(
667
+ `${label} must be boolean.`
668
+ );
669
+ }
670
+ }
671
+
672
+ assertHeaderOrFalse(
673
+ value.security.contentSecurityPolicy,
674
+ "security.contentSecurityPolicy"
675
+ );
676
+ assertFrameOptions(
677
+ value.security.frameOptions,
678
+ "security.frameOptions"
679
+ );
680
+ assertHeaderString(
681
+ value.security.referrerPolicy,
682
+ "security.referrerPolicy"
683
+ );
684
+ assertHeaderOrFalse(
685
+ value.security.permissionsPolicy,
686
+ "security.permissionsPolicy"
687
+ );
688
+ }
689
+
690
+ function assertObjectKeys(
691
+ value: unknown,
692
+ label: string,
693
+ keys: string[]
694
+ ): asserts value is Record<string, any> {
695
+ if (
696
+ !isPlainObject(
697
+ value
698
+ )
699
+ ) {
700
+ throw new Error(
701
+ `BCP Framework: ${label} must be an object.`
702
+ );
703
+ }
704
+
705
+ const allowed =
706
+ new Set(
707
+ keys
708
+ );
709
+
710
+ for (
711
+ const key
712
+ of Object.keys(
713
+ value
714
+ )
715
+ ) {
716
+ if (
717
+ !allowed.has(
718
+ key
719
+ )
720
+ ) {
721
+ throw new Error(
722
+ `BCP Framework: unknown config option "${label}.${key}".`
723
+ );
724
+ }
725
+ }
726
+ }
727
+
728
+ function assertOptionalBoolean(
729
+ value: unknown,
730
+ label: string
731
+ ): void {
732
+ if (
733
+ value !== undefined &&
734
+ typeof value !== "boolean"
735
+ ) {
736
+ throw new Error(
737
+ `BCP Framework: ${label} must be boolean.`
738
+ );
739
+ }
740
+ }
741
+
742
+ function assertPort(
743
+ value: unknown,
744
+ label: string
745
+ ): asserts value is number {
746
+ if (
747
+ !Number.isInteger(
748
+ value
749
+ ) ||
750
+ Number(value) < 1 ||
751
+ Number(value) > 65535
752
+ ) {
753
+ throw new Error(
754
+ `BCP Framework: ${label} must be an integer between 1 and 65535.`
755
+ );
756
+ }
757
+ }
758
+
759
+ function assertPositiveInteger(
760
+ value: unknown,
761
+ label: string
762
+ ): asserts value is number {
763
+ if (
764
+ !Number.isInteger(
765
+ value
766
+ ) ||
767
+ Number(value) < 1
768
+ ) {
769
+ throw new Error(
770
+ `BCP Framework: ${label} must be a positive integer.`
771
+ );
772
+ }
773
+ }
774
+
775
+ function assertOptionalHeaderOrFalse(
776
+ value: unknown,
777
+ label: string
778
+ ): void {
779
+ if (
780
+ value === undefined
781
+ ) {
782
+ return;
783
+ }
784
+
785
+ assertHeaderOrFalse(
786
+ value,
787
+ label
788
+ );
789
+ }
790
+
791
+ function assertHeaderOrFalse(
792
+ value: unknown,
793
+ label: string
794
+ ): asserts value is string | false {
795
+ if (
796
+ value === false
797
+ ) {
798
+ return;
799
+ }
800
+
801
+ assertHeaderString(
802
+ value,
803
+ label
804
+ );
805
+ }
806
+
807
+ function assertOptionalHeaderString(
808
+ value: unknown,
809
+ label: string
810
+ ): void {
811
+ if (
812
+ value === undefined
813
+ ) {
814
+ return;
815
+ }
816
+
817
+ assertHeaderString(
818
+ value,
819
+ label
820
+ );
821
+ }
822
+
823
+ function assertHeaderString(
824
+ value: unknown,
825
+ label: string
826
+ ): asserts value is string {
827
+ if (
828
+ typeof value !== "string" ||
829
+ value.trim() === ""
830
+ ) {
831
+ throw new Error(
832
+ `BCP Framework: ${label} must be a non-empty string.`
833
+ );
834
+ }
835
+
836
+ if (
837
+ value.includes("\r") ||
838
+ value.includes("\n") ||
839
+ value.includes("\0")
840
+ ) {
841
+ throw new Error(
842
+ `BCP Framework: ${label} must not contain control characters.`
843
+ );
844
+ }
845
+ }
846
+
847
+ function assertFrameOptions(
848
+ value: unknown,
849
+ label: string
850
+ ): asserts value is "DENY" | "SAMEORIGIN" | false | undefined {
851
+ if (
852
+ value === undefined ||
853
+ value === false ||
854
+ value === "DENY" ||
855
+ value === "SAMEORIGIN"
856
+ ) {
857
+ return;
858
+ }
859
+
860
+ throw new Error(
861
+ `BCP Framework: ${label} must be "DENY", "SAMEORIGIN", or false.`
862
+ );
863
+ }
864
+
865
+ function parseEnvironmentPort(
866
+ value: string | undefined
867
+ ): number | undefined {
868
+ if (
869
+ value === undefined ||
870
+ value.trim() === ""
871
+ ) {
872
+ return undefined;
873
+ }
874
+
875
+ const port =
876
+ Number(
877
+ value
878
+ );
879
+
880
+ assertPort(
881
+ port,
882
+ "BCP_PORT"
883
+ );
884
+
885
+ return port;
886
+ }
887
+
888
+ function parsePositiveInteger(
889
+ value: string | undefined,
890
+ label: string
891
+ ): number | undefined {
892
+ if (
893
+ value === undefined ||
894
+ value.trim() === ""
895
+ ) {
896
+ return undefined;
897
+ }
898
+
899
+ const parsed =
900
+ Number(
901
+ value
902
+ );
903
+
904
+ assertPositiveInteger(
905
+ parsed,
906
+ label
907
+ );
908
+
909
+ return parsed;
910
+ }
911
+
912
+ function parseEnvironmentBoolean(
913
+ value: string | undefined,
914
+ label: string
915
+ ): boolean | undefined {
916
+ if (
917
+ value === undefined ||
918
+ value.trim() === ""
919
+ ) {
920
+ return undefined;
921
+ }
922
+
923
+ const normalized =
924
+ value
925
+ .trim()
926
+ .toLowerCase();
927
+
928
+ if (
929
+ normalized === "1" ||
930
+ normalized === "true" ||
931
+ normalized === "yes" ||
932
+ normalized === "on"
933
+ ) {
934
+ return true;
935
+ }
936
+
937
+ if (
938
+ normalized === "0" ||
939
+ normalized === "false" ||
940
+ normalized === "no" ||
941
+ normalized === "off"
942
+ ) {
943
+ return false;
944
+ }
945
+
946
+ throw new Error(
947
+ `BCP Framework: ${label} must be a boolean value (true/false, 1/0, yes/no, on/off).`
948
+ );
949
+ }
950
+
951
+ function parseOptionalHeaderEnvironment(
952
+ value: string | undefined,
953
+ label: string
954
+ ): string | false | undefined {
955
+ if (
956
+ value === undefined ||
957
+ value.trim() === ""
958
+ ) {
959
+ return undefined;
960
+ }
961
+
962
+ const normalized =
963
+ value.trim();
964
+ const lowered =
965
+ normalized.toLowerCase();
966
+
967
+ if (
968
+ lowered === "0" ||
969
+ lowered === "false" ||
970
+ lowered === "off"
971
+ ) {
972
+ return false;
973
+ }
974
+
975
+ assertHeaderString(
976
+ normalized,
977
+ label
978
+ );
979
+ return normalized;
980
+ }
981
+
982
+ function parseFrameOptionsEnvironment(
983
+ value: string | undefined
984
+ ): "DENY" | "SAMEORIGIN" | false | undefined {
985
+ if (
986
+ value === undefined ||
987
+ value.trim() === ""
988
+ ) {
989
+ return undefined;
990
+ }
991
+
992
+ const normalized =
993
+ value
994
+ .trim()
995
+ .toUpperCase();
996
+
997
+ if (
998
+ normalized === "0" ||
999
+ normalized === "FALSE" ||
1000
+ normalized === "OFF"
1001
+ ) {
1002
+ return false;
1003
+ }
1004
+
1005
+ if (
1006
+ normalized === "DENY" ||
1007
+ normalized === "SAMEORIGIN"
1008
+ ) {
1009
+ return normalized;
1010
+ }
1011
+
1012
+ throw new Error(
1013
+ "BCP Framework: BCP_SECURITY_FRAME_OPTIONS must be DENY, SAMEORIGIN, or off."
1014
+ );
1015
+ }
1016
+
1017
+ function nonEmptyEnvironmentValue(
1018
+ value: string | undefined
1019
+ ): string | undefined {
1020
+ const normalized =
1021
+ value?.trim();
1022
+
1023
+ return normalized
1024
+ ? normalized
1025
+ : undefined;
1026
+ }
1027
+
1028
+ function isPlainObject(
1029
+ value: unknown
1030
+ ): value is Record<string, any> {
1031
+ return (
1032
+ typeof value === "object" &&
1033
+ value !== null &&
1034
+ !Array.isArray(
1035
+ value
1036
+ )
1037
+ );
1038
+ }