@chidchanun/bcp 0.1.8 → 0.1.9

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