@chidchanun/bcp 0.1.9 → 0.1.11

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,601 @@
1
+ import * as http from "node:http";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ runWithRequestCacheContext,
6
+ } from "../../cache/src/index.js";
7
+
8
+ import {
9
+ matchRoute,
10
+ scanRoutes,
11
+ } from "../../router/src/index.js";
12
+
13
+ import {
14
+ executePageAction,
15
+ normalizePageActionMethod,
16
+ } from "./form-action.js";
17
+
18
+ import {
19
+ completePageActionExecution,
20
+ createFormActionErrorResponse,
21
+ createFormActionTransportResponse,
22
+ isEnhancedFormActionRequest,
23
+ } from "./form-action-transport.js";
24
+
25
+ import {
26
+ sendNavigationWebResponse,
27
+ } from "./navigation-response.js";
28
+
29
+ import {
30
+ runWithRequestContext,
31
+ } from "./request-context.js";
32
+
33
+ export interface FormActionDevProxyOptions {
34
+ port: number;
35
+ hostname: string;
36
+ upstreamPort: number;
37
+ upstreamHostname?: string;
38
+ rootDirectory: string;
39
+ }
40
+
41
+ export function createFormActionDevProxy(
42
+ options: FormActionDevProxyOptions
43
+ ) {
44
+ const upstreamHostname =
45
+ options.upstreamHostname ??
46
+ "127.0.0.1";
47
+ const rootDirectory =
48
+ path.resolve(
49
+ options.rootDirectory
50
+ );
51
+ const appDirectory =
52
+ path.join(
53
+ rootDirectory,
54
+ "app"
55
+ );
56
+ let server:
57
+ http.Server | null =
58
+ null;
59
+
60
+ async function start(): Promise<void> {
61
+ if (server) {
62
+ return;
63
+ }
64
+
65
+ server =
66
+ http.createServer(
67
+ async (
68
+ req,
69
+ res
70
+ ) => {
71
+ try {
72
+ const requestUrl =
73
+ new URL(
74
+ req.url ?? "/",
75
+ `http://${req.headers.host ?? `${options.hostname}:${options.port}`}`
76
+ );
77
+
78
+ if (
79
+ requestUrl.pathname !==
80
+ "/_bcp/action"
81
+ ) {
82
+ proxyRequest(
83
+ req,
84
+ res,
85
+ upstreamHostname,
86
+ options.upstreamPort
87
+ );
88
+ return;
89
+ }
90
+
91
+ await handleActionRequest(
92
+ req,
93
+ res,
94
+ requestUrl,
95
+ appDirectory
96
+ );
97
+ } catch (error) {
98
+ console.error(
99
+ "[BCP Action Error]",
100
+ error
101
+ );
102
+
103
+ if (
104
+ res.headersSent
105
+ ) {
106
+ res.destroy();
107
+ return;
108
+ }
109
+
110
+ await sendNavigationWebResponse(
111
+ req,
112
+ res,
113
+ createFormActionErrorResponse(
114
+ "Internal Server Error",
115
+ 500
116
+ )
117
+ );
118
+ }
119
+ }
120
+ );
121
+
122
+ server.keepAliveTimeout =
123
+ 65_000;
124
+ server.headersTimeout =
125
+ 66_000;
126
+
127
+ await listen(
128
+ server,
129
+ options.port,
130
+ options.hostname
131
+ );
132
+ }
133
+
134
+ async function stop(): Promise<void> {
135
+ if (!server) {
136
+ return;
137
+ }
138
+
139
+ const current =
140
+ server;
141
+ server =
142
+ null;
143
+
144
+ await closeServer(
145
+ current
146
+ );
147
+ }
148
+
149
+ return {
150
+ start,
151
+ stop,
152
+ };
153
+ }
154
+
155
+ async function handleActionRequest(
156
+ req: http.IncomingMessage,
157
+ res: http.ServerResponse,
158
+ requestUrl: URL,
159
+ appDirectory: string
160
+ ): Promise<void> {
161
+ if (
162
+ (req.method ?? "GET")
163
+ .toUpperCase() !==
164
+ "POST"
165
+ ) {
166
+ await sendNavigationWebResponse(
167
+ req,
168
+ res,
169
+ createFormActionErrorResponse(
170
+ "Method Not Allowed",
171
+ 405
172
+ )
173
+ );
174
+ return;
175
+ }
176
+
177
+ const actionName =
178
+ requestUrl.searchParams.get(
179
+ "name"
180
+ );
181
+
182
+ if (!actionName) {
183
+ await sendNavigationWebResponse(
184
+ req,
185
+ res,
186
+ createFormActionErrorResponse(
187
+ "Missing form action name.",
188
+ 400
189
+ )
190
+ );
191
+ return;
192
+ }
193
+
194
+ const target =
195
+ resolveActionTarget(
196
+ req,
197
+ requestUrl
198
+ );
199
+
200
+ if (!target) {
201
+ await sendNavigationWebResponse(
202
+ req,
203
+ res,
204
+ createFormActionErrorResponse(
205
+ "Missing or invalid form action target.",
206
+ 400
207
+ )
208
+ );
209
+ return;
210
+ }
211
+
212
+ const method =
213
+ normalizePageActionMethod(
214
+ requestUrl.searchParams.get(
215
+ "method"
216
+ )
217
+ );
218
+ const routes =
219
+ scanRoutes(
220
+ appDirectory
221
+ );
222
+ const match =
223
+ matchRoute(
224
+ routes,
225
+ target.pathname
226
+ );
227
+
228
+ if (!match) {
229
+ await sendNavigationWebResponse(
230
+ req,
231
+ res,
232
+ createFormActionErrorResponse(
233
+ "Form action route not found.",
234
+ 404
235
+ )
236
+ );
237
+ return;
238
+ }
239
+
240
+ const formData =
241
+ await readFormData(
242
+ req,
243
+ requestUrl
244
+ );
245
+ const actionRequest =
246
+ new Request(
247
+ target,
248
+ {
249
+ method,
250
+ headers:
251
+ collectHeaders(
252
+ req
253
+ ),
254
+ }
255
+ );
256
+
257
+ const completed =
258
+ await runWithRequestContext(
259
+ actionRequest,
260
+ () =>
261
+ runWithRequestCacheContext(
262
+ target.pathname,
263
+ async () => {
264
+ const execution =
265
+ await executePageAction(
266
+ match.route.filePath,
267
+ actionName,
268
+ method,
269
+ formData,
270
+ match.params,
271
+ target,
272
+ Date.now()
273
+ );
274
+
275
+ return completePageActionExecution(
276
+ execution
277
+ );
278
+ }
279
+ ),
280
+ {
281
+ remoteAddress:
282
+ req.socket
283
+ .remoteAddress ??
284
+ null,
285
+ }
286
+ );
287
+ const response =
288
+ createFormActionTransportResponse(
289
+ completed,
290
+ target,
291
+ isEnhancedFormActionRequest(
292
+ collectHeaders(
293
+ req
294
+ )
295
+ )
296
+ );
297
+
298
+ await sendNavigationWebResponse(
299
+ req,
300
+ res,
301
+ response
302
+ );
303
+ }
304
+
305
+ function resolveActionTarget(
306
+ req: http.IncomingMessage,
307
+ requestUrl: URL
308
+ ): URL | null {
309
+ const candidates = [
310
+ requestUrl.searchParams.get(
311
+ "url"
312
+ ),
313
+ headerValue(
314
+ req.headers[
315
+ "x-bcp-action-target"
316
+ ]
317
+ ),
318
+ headerValue(
319
+ req.headers.referer
320
+ ),
321
+ ];
322
+
323
+ for (
324
+ const candidate
325
+ of candidates
326
+ ) {
327
+ if (!candidate) {
328
+ continue;
329
+ }
330
+
331
+ try {
332
+ const target =
333
+ new URL(
334
+ candidate,
335
+ requestUrl.origin
336
+ );
337
+
338
+ if (
339
+ target.origin ===
340
+ requestUrl.origin &&
341
+ target.pathname !==
342
+ "/_bcp/action"
343
+ ) {
344
+ return target;
345
+ }
346
+ } catch {
347
+ // Try the next target source.
348
+ }
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ async function readFormData(
355
+ req: http.IncomingMessage,
356
+ requestUrl: URL
357
+ ): Promise<FormData> {
358
+ const chunks:
359
+ Buffer[] = [];
360
+
361
+ for await (
362
+ const chunk
363
+ of req
364
+ ) {
365
+ chunks.push(
366
+ Buffer.isBuffer(
367
+ chunk
368
+ )
369
+ ? chunk
370
+ : Buffer.from(
371
+ chunk
372
+ )
373
+ );
374
+ }
375
+
376
+ const body =
377
+ Buffer.concat(
378
+ chunks
379
+ );
380
+ const request =
381
+ new Request(
382
+ requestUrl,
383
+ {
384
+ method: "POST",
385
+ headers:
386
+ collectHeaders(
387
+ req
388
+ ),
389
+ body:
390
+ body.length > 0
391
+ ? body
392
+ : undefined,
393
+ }
394
+ );
395
+
396
+ return request.formData();
397
+ }
398
+
399
+ function collectHeaders(
400
+ req: http.IncomingMessage
401
+ ): Headers {
402
+ const headers =
403
+ new Headers();
404
+
405
+ for (
406
+ const [
407
+ name,
408
+ value,
409
+ ]
410
+ of Object.entries(
411
+ req.headers
412
+ )
413
+ ) {
414
+ if (
415
+ value === undefined
416
+ ) {
417
+ continue;
418
+ }
419
+
420
+ if (
421
+ Array.isArray(
422
+ value
423
+ )
424
+ ) {
425
+ for (
426
+ const item
427
+ of value
428
+ ) {
429
+ headers.append(
430
+ name,
431
+ item
432
+ );
433
+ }
434
+ } else {
435
+ headers.set(
436
+ name,
437
+ value
438
+ );
439
+ }
440
+ }
441
+
442
+ return headers;
443
+ }
444
+
445
+ function headerValue(
446
+ value: string | string[] | undefined
447
+ ): string | null {
448
+ if (
449
+ Array.isArray(
450
+ value
451
+ )
452
+ ) {
453
+ return value[0] ??
454
+ null;
455
+ }
456
+
457
+ return value ??
458
+ null;
459
+ }
460
+
461
+ function proxyRequest(
462
+ req: http.IncomingMessage,
463
+ res: http.ServerResponse,
464
+ upstreamHostname: string,
465
+ upstreamPort: number
466
+ ): void {
467
+ const upstream =
468
+ http.request({
469
+ hostname:
470
+ upstreamHostname,
471
+ port:
472
+ upstreamPort,
473
+ method:
474
+ req.method ??
475
+ "GET",
476
+ path:
477
+ req.url ??
478
+ "/",
479
+ headers:
480
+ req.headers,
481
+ });
482
+
483
+ upstream.on(
484
+ "response",
485
+ (upstreamResponse) => {
486
+ res.writeHead(
487
+ upstreamResponse.statusCode ??
488
+ 502,
489
+ upstreamResponse.headers
490
+ );
491
+ upstreamResponse.pipe(
492
+ res
493
+ );
494
+ }
495
+ );
496
+
497
+ upstream.on(
498
+ "error",
499
+ (error) => {
500
+ if (
501
+ res.headersSent
502
+ ) {
503
+ res.destroy(
504
+ error
505
+ );
506
+ return;
507
+ }
508
+
509
+ res.writeHead(
510
+ 502,
511
+ {
512
+ "Content-Type":
513
+ "text/plain; charset=utf-8",
514
+ "Cache-Control":
515
+ "no-store",
516
+ }
517
+ );
518
+ res.end(
519
+ "Bad Gateway"
520
+ );
521
+ }
522
+ );
523
+
524
+ req.pipe(
525
+ upstream
526
+ );
527
+ }
528
+
529
+ function listen(
530
+ server: http.Server,
531
+ port: number,
532
+ hostname: string
533
+ ): Promise<void> {
534
+ return new Promise(
535
+ (
536
+ resolve,
537
+ reject
538
+ ) => {
539
+ const onError =
540
+ (error: Error) => {
541
+ server.off(
542
+ "listening",
543
+ onListening
544
+ );
545
+ reject(
546
+ error
547
+ );
548
+ };
549
+ const onListening =
550
+ () => {
551
+ server.off(
552
+ "error",
553
+ onError
554
+ );
555
+ resolve();
556
+ };
557
+
558
+ server.once(
559
+ "error",
560
+ onError
561
+ );
562
+ server.once(
563
+ "listening",
564
+ onListening
565
+ );
566
+ server.listen(
567
+ port,
568
+ hostname
569
+ );
570
+ }
571
+ );
572
+ }
573
+
574
+ function closeServer(
575
+ server: http.Server
576
+ ): Promise<void> {
577
+ if (
578
+ !server.listening
579
+ ) {
580
+ return Promise.resolve();
581
+ }
582
+
583
+ return new Promise(
584
+ (
585
+ resolve,
586
+ reject
587
+ ) => {
588
+ server.close(
589
+ (error) => {
590
+ if (error) {
591
+ reject(
592
+ error
593
+ );
594
+ return;
595
+ }
596
+ resolve();
597
+ }
598
+ );
599
+ }
600
+ );
601
+ }