@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,828 @@
1
+ import * as http from "node:http";
2
+
3
+ import {
4
+ createRouteCacheKey,
5
+ getCacheEntry,
6
+ setCacheEntry,
7
+ type RevalidateValue,
8
+ } from "../../cache/src/index.js";
9
+
10
+ import {
11
+ matchApiRoute,
12
+ matchRoute,
13
+ type ApiRoute,
14
+ type Route,
15
+ type RouteSegment,
16
+ } from "../../router/src/index.js";
17
+
18
+ export interface ResponseCacheRoute {
19
+ pathname: string;
20
+ segments: RouteSegment[];
21
+ revalidate: RevalidateValue;
22
+ }
23
+
24
+ export interface ResponseCacheManifest {
25
+ version: 1;
26
+ pages: ResponseCacheRoute[];
27
+ apiRoutes: ResponseCacheRoute[];
28
+ }
29
+
30
+ export interface ResponseCacheProxyOptions {
31
+ port: number;
32
+ hostname: string;
33
+ upstreamPort: number;
34
+ upstreamHostname?: string;
35
+ manifest: ResponseCacheManifest;
36
+ }
37
+
38
+ interface CacheTarget {
39
+ kind: "page" | "api" | "navigation";
40
+ pathname: string;
41
+ urlKey: string;
42
+ revalidate: RevalidateValue;
43
+ }
44
+
45
+ interface CachedHttpResponse {
46
+ statusCode: number;
47
+ statusMessage?: string;
48
+ headers: http.IncomingHttpHeaders;
49
+ body: Buffer;
50
+ }
51
+
52
+ export function createResponseCacheProxy(
53
+ options: ResponseCacheProxyOptions
54
+ ) {
55
+ const upstreamHostname =
56
+ options.upstreamHostname ??
57
+ "127.0.0.1";
58
+
59
+ const pagePatterns:
60
+ Route[] =
61
+ options.manifest.pages.map(
62
+ (route) => ({
63
+ pathname:
64
+ route.pathname,
65
+ segments:
66
+ route.segments,
67
+ filePath:
68
+ route.pathname,
69
+ layouts: [],
70
+ })
71
+ );
72
+
73
+ const apiPatterns:
74
+ ApiRoute[] =
75
+ options.manifest.apiRoutes.map(
76
+ (route) => ({
77
+ pathname:
78
+ route.pathname,
79
+ segments:
80
+ route.segments,
81
+ filePath:
82
+ route.pathname,
83
+ })
84
+ );
85
+
86
+ const pageByPathname =
87
+ new Map(
88
+ options.manifest.pages.map(
89
+ (route) => [
90
+ route.pathname,
91
+ route,
92
+ ]
93
+ )
94
+ );
95
+
96
+ const apiByPathname =
97
+ new Map(
98
+ options.manifest.apiRoutes.map(
99
+ (route) => [
100
+ route.pathname,
101
+ route,
102
+ ]
103
+ )
104
+ );
105
+
106
+ let server:
107
+ http.Server | null =
108
+ null;
109
+
110
+ async function start(): Promise<void> {
111
+ if (server) {
112
+ return;
113
+ }
114
+
115
+ server =
116
+ http.createServer(
117
+ (
118
+ req,
119
+ res
120
+ ) => {
121
+ const host =
122
+ req.headers.host ??
123
+ `${options.hostname}:${options.port}`;
124
+
125
+ let requestUrl:
126
+ URL;
127
+
128
+ try {
129
+ requestUrl =
130
+ new URL(
131
+ req.url ?? "/",
132
+ `http://${host}`
133
+ );
134
+ } catch {
135
+ proxyRequest(
136
+ req,
137
+ res,
138
+ null
139
+ );
140
+ return;
141
+ }
142
+
143
+ const target =
144
+ resolveCacheTarget(
145
+ requestUrl,
146
+ pagePatterns,
147
+ apiPatterns,
148
+ pageByPathname,
149
+ apiByPathname
150
+ );
151
+
152
+ if (
153
+ !target ||
154
+ !isCacheEligibleRequest(
155
+ req
156
+ )
157
+ ) {
158
+ proxyRequest(
159
+ req,
160
+ res,
161
+ target
162
+ ? "BYPASS"
163
+ : null
164
+ );
165
+ return;
166
+ }
167
+
168
+ const representationKey =
169
+ normalizeAcceptEncoding(
170
+ req.headers[
171
+ "accept-encoding"
172
+ ]
173
+ );
174
+
175
+ const cacheKey =
176
+ createRouteCacheKey(
177
+ target.kind,
178
+ `${target.urlKey}|accept-encoding:${representationKey}`
179
+ );
180
+
181
+ const cached =
182
+ getCacheEntry<CachedHttpResponse>(
183
+ cacheKey
184
+ );
185
+
186
+ if (cached) {
187
+ sendCachedResponse(
188
+ req,
189
+ res,
190
+ cached
191
+ );
192
+ return;
193
+ }
194
+
195
+ proxyRequest(
196
+ req,
197
+ res,
198
+ "MISS",
199
+ target,
200
+ cacheKey
201
+ );
202
+ }
203
+ );
204
+
205
+ server.keepAliveTimeout =
206
+ 65_000;
207
+ server.headersTimeout =
208
+ 66_000;
209
+
210
+ await listen(
211
+ server,
212
+ options.port,
213
+ options.hostname
214
+ );
215
+ }
216
+
217
+ async function stop(): Promise<void> {
218
+ if (!server) {
219
+ return;
220
+ }
221
+
222
+ const current =
223
+ server;
224
+ server =
225
+ null;
226
+
227
+ await closeServer(
228
+ current
229
+ );
230
+ }
231
+
232
+ function proxyRequest(
233
+ req: http.IncomingMessage,
234
+ res: http.ServerResponse,
235
+ cacheStatus:
236
+ | "MISS"
237
+ | "BYPASS"
238
+ | null,
239
+ target?: CacheTarget,
240
+ cacheKey?: string
241
+ ): void {
242
+ const upstream =
243
+ http.request(
244
+ {
245
+ hostname:
246
+ upstreamHostname,
247
+ port:
248
+ options.upstreamPort,
249
+ method:
250
+ req.method ??
251
+ "GET",
252
+ path:
253
+ req.url ??
254
+ "/",
255
+ headers:
256
+ req.headers,
257
+ }
258
+ );
259
+
260
+ upstream.on(
261
+ "response",
262
+ (upstreamResponse) => {
263
+ const chunks:
264
+ Buffer[] = [];
265
+
266
+ upstreamResponse.on(
267
+ "data",
268
+ (chunk) => {
269
+ chunks.push(
270
+ Buffer.isBuffer(
271
+ chunk
272
+ )
273
+ ? chunk
274
+ : Buffer.from(
275
+ chunk
276
+ )
277
+ );
278
+ }
279
+ );
280
+
281
+ upstreamResponse.on(
282
+ "end",
283
+ () => {
284
+ const body =
285
+ Buffer.concat(
286
+ chunks
287
+ );
288
+
289
+ const response:
290
+ CachedHttpResponse = {
291
+ statusCode:
292
+ upstreamResponse.statusCode ??
293
+ 502,
294
+ statusMessage:
295
+ upstreamResponse.statusMessage,
296
+ headers: {
297
+ ...upstreamResponse.headers,
298
+ },
299
+ body,
300
+ };
301
+
302
+ const canStore =
303
+ Boolean(
304
+ target &&
305
+ cacheKey &&
306
+ (
307
+ req.method ??
308
+ "GET"
309
+ ).toUpperCase() ===
310
+ "GET" &&
311
+ isCacheableResponse(
312
+ response,
313
+ target.kind
314
+ )
315
+ );
316
+
317
+ if (
318
+ canStore &&
319
+ target &&
320
+ cacheKey
321
+ ) {
322
+ setCacheEntry(
323
+ cacheKey,
324
+ response,
325
+ {
326
+ revalidate:
327
+ target.revalidate,
328
+ paths: [
329
+ target.pathname,
330
+ ],
331
+ tags: [
332
+ `route:${target.pathname}`,
333
+ ],
334
+ }
335
+ );
336
+ }
337
+
338
+ sendProxyResponse(
339
+ req,
340
+ res,
341
+ response,
342
+ cacheStatus
343
+ );
344
+ }
345
+ );
346
+ }
347
+ );
348
+
349
+ upstream.on(
350
+ "error",
351
+ (error) => {
352
+ console.error(
353
+ "[BCP Cache] upstream request failed:",
354
+ error
355
+ );
356
+
357
+ if (
358
+ res.headersSent
359
+ ) {
360
+ res.destroy(
361
+ error
362
+ );
363
+ return;
364
+ }
365
+
366
+ res.writeHead(
367
+ 502,
368
+ {
369
+ "Content-Type":
370
+ "text/plain; charset=utf-8",
371
+ "Cache-Control":
372
+ "no-store",
373
+ }
374
+ );
375
+ res.end(
376
+ "Bad Gateway"
377
+ );
378
+ }
379
+ );
380
+
381
+ req.pipe(
382
+ upstream
383
+ );
384
+ }
385
+
386
+ return {
387
+ start,
388
+ stop,
389
+ };
390
+ }
391
+
392
+ function resolveCacheTarget(
393
+ requestUrl: URL,
394
+ pagePatterns: Route[],
395
+ apiPatterns: ApiRoute[],
396
+ pageByPathname: Map<string, ResponseCacheRoute>,
397
+ apiByPathname: Map<string, ResponseCacheRoute>
398
+ ): CacheTarget | null {
399
+ if (
400
+ requestUrl.pathname.startsWith(
401
+ "/_bcp/client/"
402
+ ) ||
403
+ requestUrl.pathname ===
404
+ "/_bcp/dev-events"
405
+ ) {
406
+ return null;
407
+ }
408
+
409
+ if (
410
+ requestUrl.pathname ===
411
+ "/_bcp/navigation"
412
+ ) {
413
+ const rawTarget =
414
+ requestUrl.searchParams.get(
415
+ "url"
416
+ );
417
+
418
+ if (!rawTarget) {
419
+ return null;
420
+ }
421
+
422
+ let targetUrl:
423
+ URL;
424
+
425
+ try {
426
+ targetUrl =
427
+ new URL(
428
+ rawTarget,
429
+ requestUrl.origin
430
+ );
431
+ } catch {
432
+ return null;
433
+ }
434
+
435
+ if (
436
+ targetUrl.origin !==
437
+ requestUrl.origin
438
+ ) {
439
+ return null;
440
+ }
441
+
442
+ const match =
443
+ matchRoute(
444
+ pagePatterns,
445
+ normalizePathname(
446
+ targetUrl.pathname
447
+ )
448
+ );
449
+
450
+ if (!match) {
451
+ return null;
452
+ }
453
+
454
+ const definition =
455
+ pageByPathname.get(
456
+ match.route.pathname
457
+ );
458
+
459
+ if (!definition) {
460
+ return null;
461
+ }
462
+
463
+ return {
464
+ kind:
465
+ "navigation",
466
+ pathname:
467
+ normalizePathname(
468
+ targetUrl.pathname
469
+ ),
470
+ urlKey:
471
+ `${targetUrl.pathname}${targetUrl.search}`,
472
+ revalidate:
473
+ definition.revalidate,
474
+ };
475
+ }
476
+
477
+ const normalizedPathname =
478
+ normalizePathname(
479
+ requestUrl.pathname
480
+ );
481
+
482
+ const apiMatch =
483
+ matchApiRoute(
484
+ apiPatterns,
485
+ normalizedPathname
486
+ );
487
+
488
+ if (apiMatch) {
489
+ const definition =
490
+ apiByPathname.get(
491
+ apiMatch.route.pathname
492
+ );
493
+
494
+ if (definition) {
495
+ return {
496
+ kind:
497
+ "api",
498
+ pathname:
499
+ normalizedPathname,
500
+ urlKey:
501
+ `${normalizedPathname}${requestUrl.search}`,
502
+ revalidate:
503
+ definition.revalidate,
504
+ };
505
+ }
506
+ }
507
+
508
+ const pageMatch =
509
+ matchRoute(
510
+ pagePatterns,
511
+ normalizedPathname
512
+ );
513
+
514
+ if (!pageMatch) {
515
+ return null;
516
+ }
517
+
518
+ const definition =
519
+ pageByPathname.get(
520
+ pageMatch.route.pathname
521
+ );
522
+
523
+ if (!definition) {
524
+ return null;
525
+ }
526
+
527
+ return {
528
+ kind:
529
+ "page",
530
+ pathname:
531
+ normalizedPathname,
532
+ urlKey:
533
+ `${normalizedPathname}${requestUrl.search}`,
534
+ revalidate:
535
+ definition.revalidate,
536
+ };
537
+ }
538
+
539
+ function isCacheEligibleRequest(
540
+ req: http.IncomingMessage
541
+ ): boolean {
542
+ const method =
543
+ (
544
+ req.method ??
545
+ "GET"
546
+ ).toUpperCase();
547
+
548
+ if (
549
+ method !== "GET" &&
550
+ method !== "HEAD"
551
+ ) {
552
+ return false;
553
+ }
554
+
555
+ if (
556
+ req.headers.authorization ||
557
+ req.headers.cookie
558
+ ) {
559
+ return false;
560
+ }
561
+
562
+ const cacheControl =
563
+ req.headers[
564
+ "cache-control"
565
+ ] ??
566
+ "";
567
+
568
+ if (
569
+ /(?:^|,)\s*(?:no-cache|no-store|max-age=0)\b/i.test(
570
+ String(
571
+ cacheControl
572
+ )
573
+ )
574
+ ) {
575
+ return false;
576
+ }
577
+
578
+ return !/no-cache/i.test(
579
+ String(
580
+ req.headers.pragma ??
581
+ ""
582
+ )
583
+ );
584
+ }
585
+
586
+ function isCacheableResponse(
587
+ response: CachedHttpResponse,
588
+ kind: CacheTarget["kind"]
589
+ ): boolean {
590
+ if (
591
+ response.statusCode !== 200
592
+ ) {
593
+ return false;
594
+ }
595
+
596
+ if (
597
+ response.headers[
598
+ "set-cookie"
599
+ ] !== undefined
600
+ ) {
601
+ return false;
602
+ }
603
+
604
+ const cacheControl =
605
+ String(
606
+ response.headers[
607
+ "cache-control"
608
+ ] ??
609
+ ""
610
+ );
611
+
612
+ if (
613
+ /(?:private|no-cache)/i.test(
614
+ cacheControl
615
+ )
616
+ ) {
617
+ return false;
618
+ }
619
+
620
+ /*
621
+ * BCP-generated SSR HTML and navigation JSON intentionally use
622
+ * Cache-Control: no-store so browsers and intermediary caches never
623
+ * reuse framework responses on their own. Route-level `revalidate`
624
+ * controls a separate, server-side BCP cache, so that framework header
625
+ * must not prevent page/navigation entries from being stored internally.
626
+ *
627
+ * API responses are application-controlled, however. A no-store header
628
+ * returned by an API handler is explicit user intent and remains a hard
629
+ * opt-out from the BCP response cache.
630
+ */
631
+ if (
632
+ kind === "api" &&
633
+ /no-store/i.test(
634
+ cacheControl
635
+ )
636
+ ) {
637
+ return false;
638
+ }
639
+
640
+ const vary =
641
+ String(
642
+ response.headers.vary ??
643
+ ""
644
+ )
645
+ .split(",")
646
+ .map(
647
+ (value) =>
648
+ value.trim()
649
+ .toLowerCase()
650
+ )
651
+ .filter(
652
+ Boolean
653
+ );
654
+
655
+ return vary.every(
656
+ (value) =>
657
+ value ===
658
+ "accept-encoding"
659
+ );
660
+ }
661
+
662
+ function normalizeAcceptEncoding(
663
+ value:
664
+ string |
665
+ string[] |
666
+ undefined
667
+ ): string {
668
+ if (
669
+ Array.isArray(value)
670
+ ) {
671
+ return value
672
+ .join(",")
673
+ .toLowerCase();
674
+ }
675
+
676
+ return String(
677
+ value ??
678
+ "identity"
679
+ )
680
+ .trim()
681
+ .toLowerCase();
682
+ }
683
+
684
+ function sendCachedResponse(
685
+ req: http.IncomingMessage,
686
+ res: http.ServerResponse,
687
+ cached: CachedHttpResponse
688
+ ): void {
689
+ sendProxyResponse(
690
+ req,
691
+ res,
692
+ cached,
693
+ "HIT"
694
+ );
695
+ }
696
+
697
+ function sendProxyResponse(
698
+ req: http.IncomingMessage,
699
+ res: http.ServerResponse,
700
+ response: CachedHttpResponse,
701
+ cacheStatus:
702
+ | "HIT"
703
+ | "MISS"
704
+ | "BYPASS"
705
+ | null
706
+ ): void {
707
+ const headers:
708
+ http.OutgoingHttpHeaders = {
709
+ ...response.headers,
710
+ };
711
+
712
+ if (cacheStatus) {
713
+ headers[
714
+ "x-bcp-cache"
715
+ ] =
716
+ cacheStatus;
717
+ }
718
+
719
+ res.writeHead(
720
+ response.statusCode,
721
+ response.statusMessage,
722
+ headers
723
+ );
724
+
725
+ if (
726
+ (
727
+ req.method ??
728
+ "GET"
729
+ ).toUpperCase() ===
730
+ "HEAD"
731
+ ) {
732
+ res.end();
733
+ return;
734
+ }
735
+
736
+ res.end(
737
+ response.body
738
+ );
739
+ }
740
+
741
+ function normalizePathname(
742
+ pathname: string
743
+ ): string {
744
+ if (
745
+ pathname.length > 1 &&
746
+ pathname.endsWith("/")
747
+ ) {
748
+ return pathname.slice(
749
+ 0,
750
+ -1
751
+ );
752
+ }
753
+
754
+ return pathname || "/";
755
+ }
756
+
757
+ async function listen(
758
+ server: http.Server,
759
+ port: number,
760
+ hostname: string
761
+ ): Promise<void> {
762
+ await new Promise<void>(
763
+ (
764
+ resolve,
765
+ reject
766
+ ) => {
767
+ const onError =
768
+ (error: Error) => {
769
+ server.off(
770
+ "listening",
771
+ onListening
772
+ );
773
+ reject(
774
+ error
775
+ );
776
+ };
777
+
778
+ const onListening =
779
+ () => {
780
+ server.off(
781
+ "error",
782
+ onError
783
+ );
784
+ resolve();
785
+ };
786
+
787
+ server.once(
788
+ "error",
789
+ onError
790
+ );
791
+ server.once(
792
+ "listening",
793
+ onListening
794
+ );
795
+ server.listen(
796
+ port,
797
+ hostname
798
+ );
799
+ }
800
+ );
801
+ }
802
+
803
+ async function closeServer(
804
+ server: http.Server
805
+ ): Promise<void> {
806
+ if (!server.listening) {
807
+ return;
808
+ }
809
+
810
+ await new Promise<void>(
811
+ (
812
+ resolve,
813
+ reject
814
+ ) => {
815
+ server.close(
816
+ (error) => {
817
+ if (error) {
818
+ reject(
819
+ error
820
+ );
821
+ return;
822
+ }
823
+ resolve();
824
+ }
825
+ );
826
+ }
827
+ );
828
+ }