@chidchanun/bcp 0.1.9 → 0.1.10

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,815 @@
1
+ import fs from "node:fs";
2
+ import * as http from "node:http";
3
+ import * as net from "node:net";
4
+ import path from "node:path";
5
+ import {
6
+ pathToFileURL,
7
+ } from "node:url";
8
+
9
+ import {
10
+ GUARD_DATA_HEADER,
11
+ type PageLoader,
12
+ } from "./page-loader.js";
13
+
14
+ import {
15
+ createNavigationRedirectResponse,
16
+ sendNavigationWebResponse,
17
+ } from "./navigation-response.js";
18
+
19
+ import {
20
+ createStandaloneProductionServer as createBaseStandaloneProductionServer,
21
+ type StandaloneApiRoute,
22
+ type StandalonePageRoute,
23
+ type StandaloneProductionServerOptions as BaseStandaloneProductionServerOptions,
24
+ } from "./standalone-production-runtime-v2-navigation.js";
25
+
26
+ export type {
27
+ StandaloneApiRoute,
28
+ StandalonePageRoute,
29
+ };
30
+
31
+ export interface StandaloneProductionServerOptions
32
+ extends BaseStandaloneProductionServerOptions {}
33
+
34
+ interface GuardAllowResult {
35
+ kind: "allow";
36
+ data: Record<string, unknown>;
37
+ setCookies: string[];
38
+ }
39
+
40
+ interface GuardResponseResult {
41
+ kind: "response";
42
+ response: Response;
43
+ }
44
+
45
+ type GuardResult =
46
+ | GuardAllowResult
47
+ | GuardResponseResult;
48
+
49
+ interface ProductionGuardModule {
50
+ guardedPathnames: string[];
51
+ evaluateRouteGuards(
52
+ input: {
53
+ url: string;
54
+ method: string;
55
+ headers: Array<[
56
+ string,
57
+ string
58
+ ]>;
59
+ remoteAddress: string | null;
60
+ }
61
+ ): Promise<GuardResult | null>;
62
+ }
63
+
64
+ const MAX_GUARD_HEADER_BYTES =
65
+ 16 * 1024;
66
+
67
+ export function createStandaloneProductionServer(
68
+ options: StandaloneProductionServerOptions
69
+ ) {
70
+ const guardFile =
71
+ path.join(
72
+ options.buildDirectory,
73
+ "server",
74
+ "guards.mjs"
75
+ );
76
+
77
+ if (!fs.existsSync(guardFile)) {
78
+ return createBaseStandaloneProductionServer(
79
+ options
80
+ );
81
+ }
82
+
83
+ let baseServer:
84
+ ReturnType<
85
+ typeof createBaseStandaloneProductionServer
86
+ > | null =
87
+ null;
88
+ let gateway:
89
+ http.Server | null =
90
+ null;
91
+ let started =
92
+ false;
93
+ let guardModule:
94
+ ProductionGuardModule | null =
95
+ null;
96
+
97
+ async function start(): Promise<void> {
98
+ if (started) {
99
+ return;
100
+ }
101
+
102
+ guardModule =
103
+ await import(
104
+ pathToFileURL(
105
+ guardFile
106
+ ).href
107
+ ) as ProductionGuardModule;
108
+
109
+ assertGuardModule(
110
+ guardModule,
111
+ guardFile
112
+ );
113
+
114
+ const guardedPathnames =
115
+ new Set(
116
+ guardModule.guardedPathnames
117
+ );
118
+ /*
119
+ * Guard-only routes still need the loader/navigation runtime so the
120
+ * internal guard-data header is decoded and exposed to React. Return
121
+ * null instead of undefined because loader results must remain JSON
122
+ * serializable in the production runtime.
123
+ */
124
+ const guardPassthroughLoader:
125
+ PageLoader =
126
+ async () =>
127
+ null;
128
+ const routes =
129
+ options.routes.map(
130
+ (route) =>
131
+ guardedPathnames.has(
132
+ route.pathname
133
+ ) &&
134
+ route.loader ===
135
+ null
136
+ ? {
137
+ ...route,
138
+ loader:
139
+ guardPassthroughLoader,
140
+ }
141
+ : route
142
+ );
143
+ const internalPort =
144
+ await findFreePort();
145
+
146
+ baseServer =
147
+ createBaseStandaloneProductionServer({
148
+ ...options,
149
+ routes,
150
+ port:
151
+ internalPort,
152
+ hostname:
153
+ "127.0.0.1",
154
+ });
155
+
156
+ await baseServer.start();
157
+
158
+ gateway =
159
+ http.createServer(
160
+ async (
161
+ req,
162
+ res
163
+ ) => {
164
+ try {
165
+ const requestUrl =
166
+ new URL(
167
+ req.url ??
168
+ "/",
169
+ `http://${
170
+ req.headers.host ??
171
+ `${options.hostname}:${options.port}`
172
+ }`
173
+ );
174
+ const target =
175
+ resolveGuardTarget(
176
+ requestUrl
177
+ );
178
+
179
+ if (
180
+ !target ||
181
+ !guardModule
182
+ ) {
183
+ proxyRequest(
184
+ req,
185
+ res,
186
+ internalPort,
187
+ null,
188
+ []
189
+ );
190
+ return;
191
+ }
192
+
193
+ const result =
194
+ await guardModule
195
+ .evaluateRouteGuards({
196
+ url:
197
+ target.href,
198
+ method:
199
+ requestUrl.pathname ===
200
+ "/_bcp/navigation"
201
+ ? "GET"
202
+ : req.method ??
203
+ "GET",
204
+ headers:
205
+ collectRequestHeaders(
206
+ req
207
+ ),
208
+ remoteAddress:
209
+ req.socket
210
+ .remoteAddress ??
211
+ null,
212
+ });
213
+
214
+ if (!result) {
215
+ proxyRequest(
216
+ req,
217
+ res,
218
+ internalPort,
219
+ null,
220
+ []
221
+ );
222
+ return;
223
+ }
224
+
225
+ if (
226
+ result.kind ===
227
+ "response"
228
+ ) {
229
+ const response =
230
+ result.response;
231
+
232
+ if (
233
+ requestUrl.pathname ===
234
+ "/_bcp/navigation"
235
+ ) {
236
+ const navigationRedirect =
237
+ createNavigationRedirectResponse(
238
+ response
239
+ );
240
+
241
+ if (
242
+ navigationRedirect
243
+ ) {
244
+ await sendNavigationWebResponse(
245
+ req,
246
+ res,
247
+ navigationRedirect
248
+ );
249
+ return;
250
+ }
251
+ }
252
+
253
+ await sendWebResponse(
254
+ req,
255
+ res,
256
+ response
257
+ );
258
+ return;
259
+ }
260
+
261
+ const encodedData =
262
+ encodeGuardData(
263
+ result.data
264
+ );
265
+
266
+ proxyRequest(
267
+ req,
268
+ res,
269
+ internalPort,
270
+ encodedData,
271
+ result.setCookies
272
+ );
273
+ } catch (error) {
274
+ console.error(
275
+ "[BCP Guard Error]",
276
+ error
277
+ );
278
+
279
+ if (
280
+ res.headersSent
281
+ ) {
282
+ res.destroy();
283
+ return;
284
+ }
285
+
286
+ res.writeHead(
287
+ 500,
288
+ {
289
+ "Content-Type":
290
+ "text/plain; charset=utf-8",
291
+ "Cache-Control":
292
+ "no-store",
293
+ }
294
+ );
295
+ res.end(
296
+ "Internal Server Error"
297
+ );
298
+ }
299
+ }
300
+ );
301
+
302
+ gateway.keepAliveTimeout =
303
+ 65_000;
304
+ gateway.headersTimeout =
305
+ 66_000;
306
+
307
+ try {
308
+ await listen(
309
+ gateway,
310
+ options.port,
311
+ options.hostname
312
+ );
313
+ } catch (error) {
314
+ await baseServer.stop();
315
+ baseServer =
316
+ null;
317
+ gateway =
318
+ null;
319
+ guardModule =
320
+ null;
321
+ throw error;
322
+ }
323
+
324
+ started =
325
+ true;
326
+
327
+ console.log(
328
+ `[BCP Guard] Protected routes: ${guardedPathnames.size}`
329
+ );
330
+ console.log(
331
+ `[BCP Guard] Gateway: http://${options.hostname}:${options.port}`
332
+ );
333
+ console.log("");
334
+ }
335
+
336
+ async function stop(): Promise<void> {
337
+ if (gateway) {
338
+ await closeServer(
339
+ gateway
340
+ );
341
+ gateway =
342
+ null;
343
+ }
344
+
345
+ if (baseServer) {
346
+ await baseServer.stop();
347
+ baseServer =
348
+ null;
349
+ }
350
+
351
+ guardModule =
352
+ null;
353
+ started =
354
+ false;
355
+ }
356
+
357
+ return {
358
+ start,
359
+ stop,
360
+ };
361
+ }
362
+
363
+ function assertGuardModule(
364
+ value: ProductionGuardModule,
365
+ filePath: string
366
+ ): void {
367
+ if (
368
+ !Array.isArray(
369
+ value.guardedPathnames
370
+ ) ||
371
+ typeof value.evaluateRouteGuards !==
372
+ "function"
373
+ ) {
374
+ throw new Error(
375
+ `BCP Framework: invalid standalone guard module at "${filePath}".`
376
+ );
377
+ }
378
+ }
379
+
380
+ function resolveGuardTarget(
381
+ requestUrl: URL
382
+ ): URL | null {
383
+ if (
384
+ requestUrl.pathname !==
385
+ "/_bcp/navigation"
386
+ ) {
387
+ return requestUrl;
388
+ }
389
+
390
+ const targetPath =
391
+ requestUrl.searchParams.get(
392
+ "url"
393
+ );
394
+
395
+ if (!targetPath) {
396
+ return null;
397
+ }
398
+
399
+ try {
400
+ const target =
401
+ new URL(
402
+ targetPath,
403
+ requestUrl.origin
404
+ );
405
+
406
+ return target.origin ===
407
+ requestUrl.origin
408
+ ? target
409
+ : null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ function collectRequestHeaders(
416
+ req: http.IncomingMessage
417
+ ): Array<[
418
+ string,
419
+ string
420
+ ]> {
421
+ const result:
422
+ Array<[
423
+ string,
424
+ string
425
+ ]> = [];
426
+
427
+ for (
428
+ const [
429
+ key,
430
+ value,
431
+ ]
432
+ of Object.entries(
433
+ req.headers
434
+ )
435
+ ) {
436
+ if (
437
+ key.toLowerCase() ===
438
+ GUARD_DATA_HEADER ||
439
+ value ===
440
+ undefined
441
+ ) {
442
+ continue;
443
+ }
444
+
445
+ if (Array.isArray(value)) {
446
+ for (const item of value) {
447
+ result.push([
448
+ key,
449
+ item,
450
+ ]);
451
+ }
452
+ } else {
453
+ result.push([
454
+ key,
455
+ value,
456
+ ]);
457
+ }
458
+ }
459
+
460
+ return result;
461
+ }
462
+
463
+ function encodeGuardData(
464
+ data: Record<string, unknown>
465
+ ): string {
466
+ const content =
467
+ Buffer.from(
468
+ JSON.stringify(
469
+ data
470
+ ),
471
+ "utf8"
472
+ );
473
+
474
+ if (
475
+ content.length >
476
+ MAX_GUARD_HEADER_BYTES
477
+ ) {
478
+ throw new Error(
479
+ `BCP Framework: serialized guard data exceeds ${MAX_GUARD_HEADER_BYTES} bytes.`
480
+ );
481
+ }
482
+
483
+ return content.toString(
484
+ "base64url"
485
+ );
486
+ }
487
+
488
+ function proxyRequest(
489
+ req: http.IncomingMessage,
490
+ res: http.ServerResponse,
491
+ internalPort: number,
492
+ encodedGuardData: string | null,
493
+ guardCookies: string[]
494
+ ): void {
495
+ const headers:
496
+ http.OutgoingHttpHeaders = {
497
+ ...req.headers,
498
+ host:
499
+ req.headers.host,
500
+ };
501
+
502
+ delete headers[
503
+ GUARD_DATA_HEADER
504
+ ];
505
+
506
+ if (encodedGuardData) {
507
+ headers[
508
+ GUARD_DATA_HEADER
509
+ ] =
510
+ encodedGuardData;
511
+ }
512
+
513
+ const upstream =
514
+ http.request({
515
+ hostname:
516
+ "127.0.0.1",
517
+ port:
518
+ internalPort,
519
+ method:
520
+ req.method ??
521
+ "GET",
522
+ path:
523
+ req.url ??
524
+ "/",
525
+ headers,
526
+ });
527
+
528
+ upstream.on(
529
+ "response",
530
+ (upstreamResponse) => {
531
+ const responseHeaders:
532
+ http.OutgoingHttpHeaders = {
533
+ ...upstreamResponse.headers,
534
+ };
535
+ const upstreamCookies =
536
+ normalizeSetCookieHeader(
537
+ responseHeaders[
538
+ "set-cookie"
539
+ ]
540
+ );
541
+ const combinedCookies = [
542
+ ...guardCookies,
543
+ ...upstreamCookies,
544
+ ];
545
+
546
+ if (
547
+ combinedCookies.length >
548
+ 0
549
+ ) {
550
+ responseHeaders[
551
+ "set-cookie"
552
+ ] =
553
+ combinedCookies;
554
+ }
555
+
556
+ const statusCode =
557
+ upstreamResponse.statusCode ??
558
+ 502;
559
+
560
+ if (
561
+ upstreamResponse.statusMessage
562
+ ) {
563
+ res.writeHead(
564
+ statusCode,
565
+ upstreamResponse.statusMessage,
566
+ responseHeaders
567
+ );
568
+ } else {
569
+ res.writeHead(
570
+ statusCode,
571
+ responseHeaders
572
+ );
573
+ }
574
+
575
+ upstreamResponse.pipe(
576
+ res
577
+ );
578
+ }
579
+ );
580
+
581
+ upstream.on(
582
+ "error",
583
+ (error) => {
584
+ console.error(
585
+ "[BCP Guard Proxy Error]",
586
+ error
587
+ );
588
+
589
+ if (
590
+ res.headersSent
591
+ ) {
592
+ res.destroy(
593
+ error
594
+ );
595
+ return;
596
+ }
597
+
598
+ res.writeHead(
599
+ 502,
600
+ {
601
+ "Content-Type":
602
+ "text/plain; charset=utf-8",
603
+ "Cache-Control":
604
+ "no-store",
605
+ }
606
+ );
607
+ res.end(
608
+ "Bad Gateway"
609
+ );
610
+ }
611
+ );
612
+
613
+ req.pipe(
614
+ upstream
615
+ );
616
+ }
617
+
618
+ async function sendWebResponse(
619
+ req: http.IncomingMessage,
620
+ res: http.ServerResponse,
621
+ response: Response
622
+ ): Promise<void> {
623
+ const headers:
624
+ http.OutgoingHttpHeaders = {};
625
+
626
+ response.headers.forEach(
627
+ (value, key) => {
628
+ if (
629
+ key.toLowerCase() !==
630
+ "set-cookie"
631
+ ) {
632
+ headers[key] =
633
+ value;
634
+ }
635
+ }
636
+ );
637
+
638
+ const setCookies =
639
+ response.headers
640
+ .getSetCookie();
641
+
642
+ if (
643
+ setCookies.length > 0
644
+ ) {
645
+ headers[
646
+ "set-cookie"
647
+ ] =
648
+ setCookies;
649
+ }
650
+
651
+ res.writeHead(
652
+ response.status,
653
+ response.statusText,
654
+ headers
655
+ );
656
+
657
+ if (
658
+ (
659
+ req.method ??
660
+ "GET"
661
+ ).toUpperCase() ===
662
+ "HEAD" ||
663
+ !response.body
664
+ ) {
665
+ res.end();
666
+ return;
667
+ }
668
+
669
+ const reader =
670
+ response.body.getReader();
671
+
672
+ try {
673
+ while (true) {
674
+ const {
675
+ done,
676
+ value,
677
+ } =
678
+ await reader.read();
679
+
680
+ if (done) {
681
+ break;
682
+ }
683
+
684
+ res.write(
685
+ Buffer.from(
686
+ value
687
+ )
688
+ );
689
+ }
690
+
691
+ res.end();
692
+ } finally {
693
+ reader.releaseLock();
694
+ }
695
+ }
696
+
697
+ function normalizeSetCookieHeader(
698
+ value:
699
+ string |
700
+ string[] |
701
+ undefined
702
+ ): string[] {
703
+ if (!value) {
704
+ return [];
705
+ }
706
+
707
+ return Array.isArray(value)
708
+ ? value
709
+ : [
710
+ value,
711
+ ];
712
+ }
713
+
714
+ async function findFreePort(): Promise<number> {
715
+ const server =
716
+ net.createServer();
717
+
718
+ try {
719
+ await listen(
720
+ server,
721
+ 0,
722
+ "127.0.0.1"
723
+ );
724
+
725
+ const address =
726
+ server.address();
727
+
728
+ if (
729
+ !address ||
730
+ typeof address ===
731
+ "string"
732
+ ) {
733
+ throw new Error(
734
+ "BCP Framework: could not allocate guard production port."
735
+ );
736
+ }
737
+
738
+ return address.port;
739
+ } finally {
740
+ if (server.listening) {
741
+ await closeServer(
742
+ server
743
+ );
744
+ }
745
+ }
746
+ }
747
+
748
+ function listen(
749
+ server: net.Server,
750
+ port: number,
751
+ hostname: string
752
+ ): Promise<void> {
753
+ return new Promise<void>(
754
+ (
755
+ resolve,
756
+ reject
757
+ ) => {
758
+ const onError =
759
+ (error: Error) => {
760
+ server.off(
761
+ "listening",
762
+ onListening
763
+ );
764
+ reject(
765
+ error
766
+ );
767
+ };
768
+ const onListening =
769
+ () => {
770
+ server.off(
771
+ "error",
772
+ onError
773
+ );
774
+ resolve();
775
+ };
776
+
777
+ server.once(
778
+ "error",
779
+ onError
780
+ );
781
+ server.once(
782
+ "listening",
783
+ onListening
784
+ );
785
+ server.listen(
786
+ port,
787
+ hostname
788
+ );
789
+ }
790
+ );
791
+ }
792
+
793
+ function closeServer(
794
+ server: net.Server
795
+ ): Promise<void> {
796
+ return new Promise<void>(
797
+ (
798
+ resolve,
799
+ reject
800
+ ) => {
801
+ server.close(
802
+ (error) => {
803
+ if (error) {
804
+ reject(
805
+ error
806
+ );
807
+ return;
808
+ }
809
+
810
+ resolve();
811
+ }
812
+ );
813
+ }
814
+ );
815
+ }