@chidchanun/bcp 0.1.10 → 0.1.12

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