@chidchanun/bcp 0.1.21 → 0.1.23

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.
@@ -1,1951 +0,0 @@
1
- import {
2
- createHash,
3
- } from "node:crypto";
4
- import fs from "node:fs/promises";
5
- import * as http from "node:http";
6
- import path from "node:path";
7
-
8
- import {
9
- createElement,
10
- type ComponentType,
11
- type ReactElement,
12
- } from "react";
13
-
14
- import {
15
- renderToStaticMarkup,
16
- renderToString,
17
- } from "react-dom/server";
18
-
19
- import type {
20
- ProductionRouteAsset,
21
- } from "../../bundler/src/production.js";
22
-
23
- import {
24
- isNotFoundError,
25
- } from "../../client/src/not-found.js";
26
-
27
- import {
28
- matchApiRoute,
29
- matchRoute,
30
- type ApiRoute,
31
- type Route,
32
- type RouteSegment,
33
- } from "../../router/src/index.js";
34
-
35
- import {
36
- appendVary,
37
- compressDynamicContent,
38
- isCompressibleContentType,
39
- selectContentEncoding,
40
- } from "./compression.js";
41
-
42
- import {
43
- getContentType,
44
- readPublicAsset,
45
- type StaticAsset,
46
- } from "./static-assets.js";
47
-
48
- export interface StandalonePageRoute {
49
- pathname: string;
50
- segments: RouteSegment[];
51
- page: ComponentType<any>;
52
- layouts: ComponentType<any>[];
53
- loading: ComponentType<any> | null;
54
- error: ComponentType<any> | null;
55
- notFound: ComponentType<any> | null;
56
- client: ProductionRouteAsset;
57
- }
58
-
59
- export interface StandaloneApiRoute {
60
- pathname: string;
61
- segments: RouteSegment[];
62
- handlers: Record<string, unknown>;
63
- }
64
-
65
- export interface StandaloneProductionServerOptions {
66
- port: number;
67
- hostname: string;
68
- buildDirectory: string;
69
- buildId: string;
70
- version: number;
71
- routes: StandalonePageRoute[];
72
- apiRoutes: StandaloneApiRoute[];
73
- globalNotFound?: ComponentType<any> | null;
74
- globalNotFoundLayouts?: ComponentType<any>[];
75
- }
76
-
77
- interface ApiContext {
78
- params: Record<string, string>;
79
- }
80
-
81
- type ApiHandler = (
82
- request: Request,
83
- context: ApiContext
84
- ) => Response | Promise<Response>;
85
-
86
- const HTTP_METHODS = [
87
- "GET",
88
- "POST",
89
- "PUT",
90
- "PATCH",
91
- "DELETE",
92
- "HEAD",
93
- "OPTIONS",
94
- ] as const;
95
-
96
- type HttpMethod =
97
- typeof HTTP_METHODS[number];
98
-
99
- export function createStandaloneProductionServer(
100
- options: StandaloneProductionServerOptions
101
- ) {
102
- const port =
103
- options.port;
104
-
105
- const hostname =
106
- options.hostname;
107
-
108
- const buildDirectory =
109
- path.resolve(
110
- options.buildDirectory
111
- );
112
-
113
- const clientDirectory =
114
- path.join(
115
- buildDirectory,
116
- "client"
117
- );
118
-
119
- const routeByPathname =
120
- new Map(
121
- options.routes.map(
122
- (route) => [
123
- route.pathname,
124
- route,
125
- ]
126
- )
127
- );
128
-
129
- const apiByPathname =
130
- new Map(
131
- options.apiRoutes.map(
132
- (route) => [
133
- route.pathname,
134
- route,
135
- ]
136
- )
137
- );
138
-
139
- const routePatterns:
140
- Route[] =
141
- options.routes.map(
142
- (route) => ({
143
- pathname:
144
- route.pathname,
145
- segments:
146
- route.segments,
147
- filePath:
148
- route.pathname,
149
- layouts: [],
150
- })
151
- );
152
-
153
- const apiPatterns:
154
- ApiRoute[] =
155
- options.apiRoutes.map(
156
- (route) => ({
157
- pathname:
158
- route.pathname,
159
- segments:
160
- route.segments,
161
- filePath:
162
- route.pathname,
163
- })
164
- );
165
-
166
- const server =
167
- http.createServer(
168
- async (
169
- req,
170
- res
171
- ) => {
172
- const startedAt =
173
- performance.now();
174
-
175
- let pathname = "/";
176
-
177
- try {
178
- const requestUrl =
179
- new URL(
180
- req.url ?? "/",
181
- `http://${
182
- req.headers.host ??
183
- `${hostname}:${port}`
184
- }`
185
- );
186
-
187
- pathname =
188
- normalizePathname(
189
- requestUrl.pathname
190
- );
191
-
192
- res.once(
193
- "finish",
194
- () => {
195
- const duration =
196
- performance.now() -
197
- startedAt;
198
-
199
- console.log(
200
- `${req.method ?? "GET"} ${pathname} ${res.statusCode} ${duration.toFixed(2)}ms`
201
- );
202
- }
203
- );
204
-
205
- if (
206
- pathname ===
207
- "/_bcp/navigation"
208
- ) {
209
- await handleNavigation(
210
- req,
211
- res,
212
- requestUrl,
213
- routePatterns,
214
- routeByPathname,
215
- options.version
216
- );
217
- return;
218
- }
219
-
220
- if (
221
- pathname.startsWith(
222
- "/_bcp/client/"
223
- )
224
- ) {
225
- const served =
226
- await serveBuildAsset(
227
- req,
228
- res,
229
- clientDirectory,
230
- pathname
231
- );
232
-
233
- if (!served) {
234
- sendSimpleNotFound(
235
- req,
236
- res
237
- );
238
- }
239
- return;
240
- }
241
-
242
- const apiMatch =
243
- matchApiRoute(
244
- apiPatterns,
245
- pathname
246
- );
247
-
248
- if (apiMatch) {
249
- const definition =
250
- apiByPathname.get(
251
- apiMatch.route.pathname
252
- );
253
-
254
- if (!definition) {
255
- throw new Error(
256
- `BCP Framework: standalone API definition missing for "${apiMatch.route.pathname}".`
257
- );
258
- }
259
-
260
- try {
261
- await handleApiRoute(
262
- req,
263
- res,
264
- requestUrl,
265
- definition,
266
- apiMatch.params
267
- );
268
- } catch (error) {
269
- console.error(
270
- `[BCP API Error] ${req.method ?? "GET"} ${pathname}:`,
271
- error
272
- );
273
-
274
- if (!res.headersSent) {
275
- sendJson(
276
- req,
277
- res,
278
- 500,
279
- {
280
- error:
281
- "Internal Server Error",
282
- }
283
- );
284
- } else {
285
- res.end();
286
- }
287
- }
288
- return;
289
- }
290
-
291
- const publicAsset =
292
- await readPublicAsset(
293
- buildDirectory,
294
- pathname
295
- );
296
-
297
- if (publicAsset) {
298
- sendPublicAsset(
299
- req,
300
- res,
301
- publicAsset
302
- );
303
- return;
304
- }
305
-
306
- const match =
307
- matchRoute(
308
- routePatterns,
309
- pathname
310
- );
311
-
312
- if (!match) {
313
- sendGlobalNotFound(
314
- req,
315
- res,
316
- options
317
- );
318
- return;
319
- }
320
-
321
- const definition =
322
- routeByPathname.get(
323
- match.route.pathname
324
- );
325
-
326
- if (!definition) {
327
- throw new Error(
328
- `BCP Framework: standalone page definition missing for "${match.route.pathname}".`
329
- );
330
- }
331
-
332
- sendPage(
333
- req,
334
- res,
335
- definition,
336
- match.params,
337
- options
338
- );
339
- } catch (error) {
340
- console.error(
341
- "BCP Standalone Production Error:",
342
- error
343
- );
344
-
345
- if (
346
- res.headersSent
347
- ) {
348
- res.end();
349
- return;
350
- }
351
-
352
- sendInternalServerError(
353
- req,
354
- res,
355
- error
356
- );
357
- }
358
- }
359
- );
360
-
361
- return {
362
- async start() {
363
- process.env.NODE_ENV =
364
- "production";
365
-
366
- await new Promise<void>(
367
- (
368
- resolve,
369
- reject
370
- ) => {
371
- const onError =
372
- (error: Error) => {
373
- server.off(
374
- "listening",
375
- onListening
376
- );
377
- reject(error);
378
- };
379
-
380
- const onListening =
381
- () => {
382
- server.off(
383
- "error",
384
- onError
385
- );
386
- resolve();
387
- };
388
-
389
- server.once(
390
- "error",
391
- onError
392
- );
393
- server.once(
394
- "listening",
395
- onListening
396
- );
397
- server.listen(
398
- port,
399
- hostname
400
- );
401
- }
402
- );
403
-
404
- console.log("");
405
- console.log(
406
- "BCP Framework Standalone Production"
407
- );
408
- console.log(
409
- `Local: http://${hostname}:${port}`
410
- );
411
- console.log(
412
- `Build: ${options.buildId}`
413
- );
414
- console.log("");
415
- },
416
-
417
- async stop() {
418
- if (!server.listening) {
419
- return;
420
- }
421
-
422
- await new Promise<void>(
423
- (
424
- resolve,
425
- reject
426
- ) => {
427
- server.close(
428
- (error) => {
429
- if (error) {
430
- reject(error);
431
- return;
432
- }
433
-
434
- resolve();
435
- }
436
- );
437
- }
438
- );
439
- },
440
- };
441
- }
442
-
443
- function sendPage(
444
- req: http.IncomingMessage,
445
- res: http.ServerResponse,
446
- definition: StandalonePageRoute,
447
- params: Record<string, string>,
448
- options: StandaloneProductionServerOptions
449
- ): void {
450
- try {
451
- const html =
452
- renderPageDocument(
453
- definition,
454
- params
455
- );
456
-
457
- sendHtml(
458
- req,
459
- res,
460
- 200,
461
- html
462
- );
463
- } catch (error) {
464
- if (
465
- isNotFoundError(
466
- error
467
- )
468
- ) {
469
- const NotFoundComponent =
470
- definition.notFound ??
471
- options.globalNotFound ??
472
- null;
473
-
474
- const html =
475
- renderFallbackDocument(
476
- NotFoundComponent,
477
- definition.layouts,
478
- params,
479
- {
480
- kind:
481
- "not-found",
482
- }
483
- );
484
-
485
- sendHtml(
486
- req,
487
- res,
488
- 404,
489
- html
490
- );
491
- return;
492
- }
493
-
494
- console.error(
495
- `[BCP Page Error] ${definition.pathname}:`,
496
- error
497
- );
498
-
499
- const html =
500
- renderFallbackDocument(
501
- definition.error,
502
- definition.layouts,
503
- params,
504
- {
505
- kind:
506
- "error",
507
- error:
508
- createPublicServerError(
509
- error
510
- ),
511
- }
512
- );
513
-
514
- sendHtml(
515
- req,
516
- res,
517
- 500,
518
- html
519
- );
520
- }
521
- }
522
-
523
- function sendGlobalNotFound(
524
- req: http.IncomingMessage,
525
- res: http.ServerResponse,
526
- options: StandaloneProductionServerOptions
527
- ): void {
528
- const html =
529
- renderFallbackDocument(
530
- options.globalNotFound ??
531
- null,
532
- options.globalNotFoundLayouts ??
533
- [],
534
- {},
535
- {
536
- kind:
537
- "not-found",
538
- }
539
- );
540
-
541
- sendHtml(
542
- req,
543
- res,
544
- 404,
545
- html
546
- );
547
- }
548
-
549
- function renderPageDocument(
550
- definition: StandalonePageRoute,
551
- params: Record<string, string>
552
- ): string {
553
- const element =
554
- wrapWithLayouts(
555
- createElement(
556
- definition.page,
557
- {
558
- params,
559
- }
560
- ),
561
- definition.layouts,
562
- params
563
- );
564
-
565
- const content =
566
- renderToString(
567
- element
568
- );
569
-
570
- return renderDocument({
571
- content,
572
- route:
573
- definition.pathname,
574
- params,
575
- client:
576
- definition.client,
577
- });
578
- }
579
-
580
- function renderFallbackDocument(
581
- Component: ComponentType<any> | null,
582
- layouts: ComponentType<any>[],
583
- params: Record<string, string>,
584
- fallback:
585
- | {
586
- kind: "not-found";
587
- }
588
- | {
589
- kind: "error";
590
- error: Error;
591
- }
592
- ): string {
593
- if (!Component) {
594
- return fallback.kind ===
595
- "not-found"
596
- ? genericNotFoundDocument()
597
- : genericErrorDocument(
598
- getErrorDigest(
599
- fallback.error
600
- )
601
- );
602
- }
603
-
604
- const props =
605
- fallback.kind ===
606
- "error"
607
- ? {
608
- error:
609
- fallback.error,
610
- reset:
611
- () => undefined,
612
- }
613
- : {};
614
-
615
- try {
616
- const content =
617
- renderToString(
618
- wrapWithLayouts(
619
- createElement(
620
- Component,
621
- props
622
- ),
623
- layouts,
624
- params
625
- )
626
- );
627
-
628
- return renderDocument({
629
- content,
630
- route: null,
631
- params: null,
632
- client: null,
633
- });
634
- } catch (fallbackError) {
635
- console.error(
636
- `[BCP ${fallback.kind} fallback error]:`,
637
- fallbackError
638
- );
639
-
640
- try {
641
- const content =
642
- renderToString(
643
- createElement(
644
- Component,
645
- props
646
- )
647
- );
648
-
649
- return renderDocument({
650
- content,
651
- route: null,
652
- params: null,
653
- client: null,
654
- });
655
- } catch (componentError) {
656
- console.error(
657
- `[BCP ${fallback.kind} component error]:`,
658
- componentError
659
- );
660
-
661
- return fallback.kind ===
662
- "not-found"
663
- ? genericNotFoundDocument()
664
- : genericErrorDocument(
665
- getErrorDigest(
666
- fallback.error
667
- )
668
- );
669
- }
670
- }
671
- }
672
-
673
- function wrapWithLayouts(
674
- child: ReactElement,
675
- layouts: ComponentType<any>[],
676
- params: Record<string, string>
677
- ): ReactElement {
678
- let element =
679
- child;
680
-
681
- for (
682
- let index =
683
- layouts.length - 1;
684
- index >= 0;
685
- index--
686
- ) {
687
- element =
688
- createElement(
689
- layouts[index],
690
- {
691
- params,
692
- },
693
- element
694
- );
695
- }
696
-
697
- return element;
698
- }
699
-
700
- function renderDocument(
701
- input: {
702
- content: string;
703
- route: string | null;
704
- params: Record<string, string> | null;
705
- client: ProductionRouteAsset | null;
706
- }
707
- ): string {
708
- const modulePreloads =
709
- input.client
710
- ? input.client.imports
711
- .map(
712
- (importPath) =>
713
- ` <link rel="modulepreload" href="${escapeHtmlAttribute(
714
- importPath
715
- )}" />`
716
- )
717
- .join("\n")
718
- : "";
719
-
720
- const frameworkData =
721
- input.route !== null &&
722
- input.params !== null
723
- ? serializeFrameworkData({
724
- route:
725
- input.route,
726
- params:
727
- input.params,
728
- })
729
- : null;
730
-
731
- const clientRuntime =
732
- input.client &&
733
- frameworkData !== null
734
- ? `
735
- <script
736
- id="__BCP_DATA__"
737
- type="application/json"
738
- >${frameworkData}</script>
739
-
740
- <script
741
- data-bcp-client
742
- type="module"
743
- src="${escapeHtmlAttribute(
744
- input.client.entry
745
- )}"
746
- ></script>`
747
- : "";
748
-
749
- return `
750
- <!DOCTYPE html>
751
- <html lang="en">
752
- <head>
753
- <meta charset="UTF-8" />
754
- <meta
755
- name="viewport"
756
- content="width=device-width, initial-scale=1.0"
757
- />
758
- <title>BCP Framework</title>
759
- ${modulePreloads}
760
- </head>
761
- <body>
762
- <div id="root">${input.content}</div>
763
- ${clientRuntime}
764
- </body>
765
- </html>
766
- `.trim();
767
- }
768
-
769
- function createPublicServerError(
770
- error: unknown
771
- ): Error {
772
- const digest =
773
- createErrorDigest(
774
- error
775
- );
776
-
777
- const publicError =
778
- new Error(
779
- "An unexpected server error occurred."
780
- ) as Error & {
781
- digest?: string;
782
- };
783
-
784
- publicError.name =
785
- "BcpServerError";
786
- publicError.digest =
787
- digest;
788
-
789
- return publicError;
790
- }
791
-
792
- function createErrorDigest(
793
- error: unknown
794
- ): string {
795
- const source =
796
- error instanceof Error
797
- ? `${error.name}\n${error.message}\n${error.stack ?? ""}`
798
- : String(error);
799
-
800
- return createHash(
801
- "sha256"
802
- )
803
- .update(
804
- source
805
- )
806
- .digest(
807
- "hex"
808
- )
809
- .slice(
810
- 0,
811
- 12
812
- );
813
- }
814
-
815
- function getErrorDigest(
816
- error: Error
817
- ): string {
818
- const value =
819
- error as Error & {
820
- digest?: string;
821
- };
822
-
823
- return value.digest ??
824
- "unknown";
825
- }
826
-
827
- async function handleNavigation(
828
- req: http.IncomingMessage,
829
- res: http.ServerResponse,
830
- requestUrl: URL,
831
- routes: Route[],
832
- routeByPathname: Map<
833
- string,
834
- StandalonePageRoute
835
- >,
836
- version: number
837
- ): Promise<void> {
838
- if (
839
- req.method !== "GET"
840
- ) {
841
- sendJson(
842
- req,
843
- res,
844
- 405,
845
- {
846
- error:
847
- "Method Not Allowed",
848
- }
849
- );
850
- return;
851
- }
852
-
853
- const targetPath =
854
- requestUrl.searchParams.get(
855
- "url"
856
- );
857
-
858
- if (!targetPath) {
859
- sendJson(
860
- req,
861
- res,
862
- 400,
863
- {
864
- error:
865
- "Missing navigation target.",
866
- }
867
- );
868
- return;
869
- }
870
-
871
- let target: URL;
872
-
873
- try {
874
- target =
875
- new URL(
876
- targetPath,
877
- requestUrl.origin
878
- );
879
- } catch {
880
- sendJson(
881
- req,
882
- res,
883
- 400,
884
- {
885
- error:
886
- "Invalid navigation target.",
887
- }
888
- );
889
- return;
890
- }
891
-
892
- if (
893
- target.origin !==
894
- requestUrl.origin
895
- ) {
896
- sendJson(
897
- req,
898
- res,
899
- 400,
900
- {
901
- error:
902
- "Cross-origin navigation is not allowed.",
903
- }
904
- );
905
- return;
906
- }
907
-
908
- const match =
909
- matchRoute(
910
- routes,
911
- normalizePathname(
912
- target.pathname
913
- )
914
- );
915
-
916
- if (!match) {
917
- sendJson(
918
- req,
919
- res,
920
- 404,
921
- {
922
- error:
923
- "Route not found.",
924
- }
925
- );
926
- return;
927
- }
928
-
929
- const definition =
930
- routeByPathname.get(
931
- match.route.pathname
932
- );
933
-
934
- if (!definition) {
935
- sendJson(
936
- req,
937
- res,
938
- 404,
939
- {
940
- error:
941
- "Route not found.",
942
- }
943
- );
944
- return;
945
- }
946
-
947
- const loadingHtml =
948
- definition.loading
949
- ? renderToStaticMarkup(
950
- createElement(
951
- definition.loading,
952
- {
953
- params:
954
- match.params,
955
- }
956
- )
957
- )
958
- : null;
959
-
960
- sendJson(
961
- req,
962
- res,
963
- 200,
964
- {
965
- route:
966
- definition.pathname,
967
- params:
968
- match.params,
969
- clientScript:
970
- definition.client.entry,
971
- clientImports:
972
- definition.client.imports,
973
- title:
974
- "BCP Framework",
975
- version,
976
- loadingHtml,
977
- }
978
- );
979
- }
980
-
981
- async function serveBuildAsset(
982
- req: http.IncomingMessage,
983
- res: http.ServerResponse,
984
- clientDirectory: string,
985
- pathname: string
986
- ): Promise<boolean> {
987
- const encodedRelative =
988
- pathname.slice(
989
- "/_bcp/client/".length
990
- );
991
-
992
- let relativePath: string;
993
-
994
- try {
995
- relativePath =
996
- decodeURIComponent(
997
- encodedRelative
998
- );
999
- } catch {
1000
- return false;
1001
- }
1002
-
1003
- if (
1004
- !relativePath ||
1005
- relativePath.includes(
1006
- "\0"
1007
- )
1008
- ) {
1009
- return false;
1010
- }
1011
-
1012
- const root =
1013
- path.resolve(
1014
- clientDirectory
1015
- );
1016
-
1017
- const candidate =
1018
- path.resolve(
1019
- root,
1020
- relativePath.replaceAll(
1021
- "/",
1022
- path.sep
1023
- )
1024
- );
1025
-
1026
- if (
1027
- !isPathInside(
1028
- root,
1029
- candidate
1030
- )
1031
- ) {
1032
- return false;
1033
- }
1034
-
1035
- let stat:
1036
- Awaited<
1037
- ReturnType<typeof fs.stat>
1038
- >;
1039
-
1040
- try {
1041
- stat =
1042
- await fs.stat(
1043
- candidate
1044
- );
1045
- } catch {
1046
- return false;
1047
- }
1048
-
1049
- if (!stat.isFile()) {
1050
- return false;
1051
- }
1052
-
1053
- const contentType =
1054
- getContentType(
1055
- candidate
1056
- );
1057
-
1058
- const encoding =
1059
- selectContentEncoding(
1060
- req,
1061
- contentType,
1062
- stat.size,
1063
- {
1064
- br:
1065
- await fileExists(
1066
- `${candidate}.br`
1067
- ),
1068
- gzip:
1069
- await fileExists(
1070
- `${candidate}.gz`
1071
- ),
1072
- }
1073
- );
1074
-
1075
- const sourceFile =
1076
- encoding === "br"
1077
- ? `${candidate}.br`
1078
- : encoding === "gzip"
1079
- ? `${candidate}.gz`
1080
- : candidate;
1081
-
1082
- const content =
1083
- await fs.readFile(
1084
- sourceFile
1085
- );
1086
-
1087
- const headers:
1088
- Record<string, string | number> = {
1089
- "Content-Type":
1090
- contentType,
1091
- "Cache-Control":
1092
- "public, max-age=31536000, immutable",
1093
- "X-Content-Type-Options":
1094
- "nosniff",
1095
- "Content-Length":
1096
- content.length,
1097
- Vary:
1098
- "Accept-Encoding",
1099
- };
1100
-
1101
- if (encoding) {
1102
- headers[
1103
- "Content-Encoding"
1104
- ] =
1105
- encoding;
1106
- }
1107
-
1108
- res.writeHead(
1109
- 200,
1110
- headers
1111
- );
1112
-
1113
- if (
1114
- req.method === "HEAD"
1115
- ) {
1116
- res.end();
1117
- return true;
1118
- }
1119
-
1120
- res.end(
1121
- content
1122
- );
1123
- return true;
1124
- }
1125
-
1126
- async function fileExists(
1127
- filePath: string
1128
- ): Promise<boolean> {
1129
- try {
1130
- const stat =
1131
- await fs.stat(
1132
- filePath
1133
- );
1134
- return stat.isFile();
1135
- } catch {
1136
- return false;
1137
- }
1138
- }
1139
-
1140
- function sendPublicAsset(
1141
- req: http.IncomingMessage,
1142
- res: http.ServerResponse,
1143
- asset: StaticAsset
1144
- ): void {
1145
- const encoding =
1146
- selectContentEncoding(
1147
- req,
1148
- asset.contentType,
1149
- asset.content.length
1150
- );
1151
-
1152
- const content =
1153
- compressDynamicContent(
1154
- asset.content,
1155
- encoding
1156
- );
1157
-
1158
- const etag =
1159
- createVariantEtag(
1160
- asset.etag,
1161
- encoding
1162
- );
1163
-
1164
- const ifNoneMatch =
1165
- req.headers[
1166
- "if-none-match"
1167
- ];
1168
-
1169
- const values =
1170
- Array.isArray(
1171
- ifNoneMatch
1172
- )
1173
- ? ifNoneMatch
1174
- : ifNoneMatch
1175
- ? ifNoneMatch.split(",")
1176
- : [];
1177
-
1178
- if (
1179
- values
1180
- .map(
1181
- (value) =>
1182
- value.trim()
1183
- )
1184
- .includes(
1185
- etag
1186
- )
1187
- ) {
1188
- res.writeHead(
1189
- 304,
1190
- {
1191
- ETag:
1192
- etag,
1193
- "Cache-Control":
1194
- "public, max-age=0, must-revalidate",
1195
- Vary:
1196
- "Accept-Encoding",
1197
- }
1198
- );
1199
- res.end();
1200
- return;
1201
- }
1202
-
1203
- const headers:
1204
- Record<string, string | number> = {
1205
- "Content-Type":
1206
- asset.contentType,
1207
- "Content-Length":
1208
- content.length,
1209
- ETag:
1210
- etag,
1211
- "Last-Modified":
1212
- asset.lastModified,
1213
- "Cache-Control":
1214
- "public, max-age=0, must-revalidate",
1215
- "X-Content-Type-Options":
1216
- "nosniff",
1217
- };
1218
-
1219
- if (
1220
- isCompressibleContentType(
1221
- asset.contentType
1222
- )
1223
- ) {
1224
- headers.Vary =
1225
- "Accept-Encoding";
1226
- }
1227
-
1228
- if (encoding) {
1229
- headers[
1230
- "Content-Encoding"
1231
- ] =
1232
- encoding;
1233
- }
1234
-
1235
- res.writeHead(
1236
- 200,
1237
- headers
1238
- );
1239
-
1240
- if (
1241
- req.method === "HEAD"
1242
- ) {
1243
- res.end();
1244
- return;
1245
- }
1246
-
1247
- res.end(
1248
- content
1249
- );
1250
- }
1251
-
1252
- async function handleApiRoute(
1253
- req: http.IncomingMessage,
1254
- res: http.ServerResponse,
1255
- requestUrl: URL,
1256
- definition: StandaloneApiRoute,
1257
- params: Record<string, string>
1258
- ): Promise<void> {
1259
- const method =
1260
- normalizeHttpMethod(
1261
- req.method
1262
- );
1263
-
1264
- const allowedMethods =
1265
- getAllowedMethods(
1266
- definition.handlers
1267
- );
1268
-
1269
- if (
1270
- method === "OPTIONS" &&
1271
- !getApiHandler(
1272
- definition.handlers,
1273
- "OPTIONS"
1274
- )
1275
- ) {
1276
- res.writeHead(
1277
- 204,
1278
- {
1279
- Allow:
1280
- allowedMethods.join(
1281
- ", "
1282
- ),
1283
- }
1284
- );
1285
- res.end();
1286
- return;
1287
- }
1288
-
1289
- let handler =
1290
- getApiHandler(
1291
- definition.handlers,
1292
- method
1293
- );
1294
-
1295
- if (
1296
- !handler &&
1297
- method === "HEAD"
1298
- ) {
1299
- handler =
1300
- getApiHandler(
1301
- definition.handlers,
1302
- "GET"
1303
- );
1304
- }
1305
-
1306
- if (!handler) {
1307
- sendJson(
1308
- req,
1309
- res,
1310
- 405,
1311
- {
1312
- error:
1313
- "Method Not Allowed",
1314
- allowedMethods,
1315
- },
1316
- {
1317
- Allow:
1318
- allowedMethods.join(
1319
- ", "
1320
- ),
1321
- }
1322
- );
1323
- return;
1324
- }
1325
-
1326
- const response =
1327
- await handler(
1328
- await createWebRequest(
1329
- req,
1330
- requestUrl
1331
- ),
1332
- {
1333
- params,
1334
- }
1335
- );
1336
-
1337
- if (
1338
- !(response instanceof Response)
1339
- ) {
1340
- throw new Error(
1341
- `API handler "${method} ${definition.pathname}" must return a Response object.`
1342
- );
1343
- }
1344
-
1345
- await sendWebResponse(
1346
- req,
1347
- res,
1348
- response,
1349
- method === "HEAD"
1350
- );
1351
- }
1352
-
1353
- function normalizeHttpMethod(
1354
- method: string | undefined
1355
- ): HttpMethod {
1356
- return (
1357
- (
1358
- method ??
1359
- "GET"
1360
- ).toUpperCase() as
1361
- HttpMethod
1362
- );
1363
- }
1364
-
1365
- function getApiHandler(
1366
- module: Record<string, unknown>,
1367
- method: string
1368
- ): ApiHandler | undefined {
1369
- const value =
1370
- module[
1371
- method
1372
- ];
1373
-
1374
- return typeof value ===
1375
- "function"
1376
- ? value as ApiHandler
1377
- : undefined;
1378
- }
1379
-
1380
- function getAllowedMethods(
1381
- module: Record<string, unknown>
1382
- ): string[] {
1383
- const allowed:
1384
- string[] = [];
1385
-
1386
- for (
1387
- const method
1388
- of HTTP_METHODS
1389
- ) {
1390
- if (
1391
- method === "OPTIONS"
1392
- ) {
1393
- continue;
1394
- }
1395
-
1396
- if (
1397
- method === "HEAD"
1398
- ) {
1399
- if (
1400
- getApiHandler(
1401
- module,
1402
- "HEAD"
1403
- ) ||
1404
- getApiHandler(
1405
- module,
1406
- "GET"
1407
- )
1408
- ) {
1409
- allowed.push(
1410
- "HEAD"
1411
- );
1412
- }
1413
- continue;
1414
- }
1415
-
1416
- if (
1417
- getApiHandler(
1418
- module,
1419
- method
1420
- )
1421
- ) {
1422
- allowed.push(
1423
- method
1424
- );
1425
- }
1426
- }
1427
-
1428
- allowed.push(
1429
- "OPTIONS"
1430
- );
1431
- return allowed;
1432
- }
1433
-
1434
- async function createWebRequest(
1435
- req: http.IncomingMessage,
1436
- url: URL
1437
- ): Promise<Request> {
1438
- const headers =
1439
- new Headers();
1440
-
1441
- for (
1442
- const [
1443
- key,
1444
- value,
1445
- ]
1446
- of Object.entries(
1447
- req.headers
1448
- )
1449
- ) {
1450
- if (
1451
- value ===
1452
- undefined
1453
- ) {
1454
- continue;
1455
- }
1456
-
1457
- if (
1458
- Array.isArray(
1459
- value
1460
- )
1461
- ) {
1462
- for (
1463
- const item
1464
- of value
1465
- ) {
1466
- headers.append(
1467
- key,
1468
- item
1469
- );
1470
- }
1471
- } else {
1472
- headers.set(
1473
- key,
1474
- value
1475
- );
1476
- }
1477
- }
1478
-
1479
- const method =
1480
- (
1481
- req.method ??
1482
- "GET"
1483
- ).toUpperCase();
1484
-
1485
- const init:
1486
- RequestInit = {
1487
- method,
1488
- headers,
1489
- };
1490
-
1491
- if (
1492
- method !== "GET" &&
1493
- method !== "HEAD"
1494
- ) {
1495
- const body =
1496
- await readRequestBody(
1497
- req
1498
- );
1499
-
1500
- if (
1501
- body.length > 0
1502
- ) {
1503
- init.body =
1504
- body as unknown as
1505
- BodyInit;
1506
- }
1507
- }
1508
-
1509
- return new Request(
1510
- url,
1511
- init
1512
- );
1513
- }
1514
-
1515
- async function readRequestBody(
1516
- req: http.IncomingMessage
1517
- ): Promise<Buffer> {
1518
- const chunks:
1519
- Buffer[] = [];
1520
-
1521
- for await (
1522
- const chunk
1523
- of req
1524
- ) {
1525
- chunks.push(
1526
- Buffer.isBuffer(
1527
- chunk
1528
- )
1529
- ? chunk
1530
- : Buffer.from(
1531
- chunk
1532
- )
1533
- );
1534
- }
1535
-
1536
- return Buffer.concat(
1537
- chunks
1538
- );
1539
- }
1540
-
1541
- async function sendWebResponse(
1542
- req: http.IncomingMessage,
1543
- res: http.ServerResponse,
1544
- response: Response,
1545
- isHead: boolean
1546
- ): Promise<void> {
1547
- res.statusCode =
1548
- response.status;
1549
-
1550
- response.headers.forEach(
1551
- (
1552
- value,
1553
- key
1554
- ) => {
1555
- const normalized =
1556
- key.toLowerCase();
1557
-
1558
- if (
1559
- normalized ===
1560
- "set-cookie" ||
1561
- normalized ===
1562
- "content-length"
1563
- ) {
1564
- return;
1565
- }
1566
-
1567
- res.setHeader(
1568
- key,
1569
- value
1570
- );
1571
- }
1572
- );
1573
-
1574
- const responseHeaders =
1575
- response.headers as
1576
- Headers & {
1577
- getSetCookie?: () =>
1578
- string[];
1579
- };
1580
-
1581
- const cookies =
1582
- responseHeaders
1583
- .getSetCookie?.();
1584
-
1585
- if (
1586
- cookies &&
1587
- cookies.length > 0
1588
- ) {
1589
- res.setHeader(
1590
- "Set-Cookie",
1591
- cookies
1592
- );
1593
- }
1594
-
1595
- if (
1596
- response.status === 204 ||
1597
- response.status === 304
1598
- ) {
1599
- res.end();
1600
- return;
1601
- }
1602
-
1603
- const rawBody =
1604
- Buffer.from(
1605
- await response.arrayBuffer()
1606
- );
1607
-
1608
- const contentType =
1609
- response.headers.get(
1610
- "content-type"
1611
- ) ??
1612
- "application/octet-stream";
1613
-
1614
- const existingEncoding =
1615
- response.headers.get(
1616
- "content-encoding"
1617
- );
1618
-
1619
- const encoding =
1620
- existingEncoding
1621
- ? null
1622
- : selectContentEncoding(
1623
- req,
1624
- contentType,
1625
- rawBody.length
1626
- );
1627
-
1628
- const body =
1629
- existingEncoding
1630
- ? rawBody
1631
- : compressDynamicContent(
1632
- rawBody,
1633
- encoding
1634
- );
1635
-
1636
- if (encoding) {
1637
- res.setHeader(
1638
- "Content-Encoding",
1639
- encoding
1640
- );
1641
- res.setHeader(
1642
- "Vary",
1643
- appendVary(
1644
- res.getHeader(
1645
- "Vary"
1646
- )?.toString(),
1647
- "Accept-Encoding"
1648
- )
1649
- );
1650
- }
1651
-
1652
- res.setHeader(
1653
- "Content-Length",
1654
- body.length
1655
- );
1656
-
1657
- if (isHead) {
1658
- res.end();
1659
- return;
1660
- }
1661
-
1662
- res.end(
1663
- body
1664
- );
1665
- }
1666
-
1667
- function sendJson(
1668
- req: http.IncomingMessage,
1669
- res: http.ServerResponse,
1670
- statusCode: number,
1671
- value: unknown,
1672
- extraHeaders: Record<string, string> = {}
1673
- ): void {
1674
- sendCompressedBody(
1675
- req,
1676
- res,
1677
- statusCode,
1678
- "application/json; charset=utf-8",
1679
- Buffer.from(
1680
- JSON.stringify(
1681
- value
1682
- )
1683
- ),
1684
- {
1685
- "Cache-Control":
1686
- "no-store",
1687
- "X-Content-Type-Options":
1688
- "nosniff",
1689
- ...extraHeaders,
1690
- }
1691
- );
1692
- }
1693
-
1694
- function sendHtml(
1695
- req: http.IncomingMessage,
1696
- res: http.ServerResponse,
1697
- statusCode: number,
1698
- html: string
1699
- ): void {
1700
- sendCompressedBody(
1701
- req,
1702
- res,
1703
- statusCode,
1704
- "text/html; charset=utf-8",
1705
- Buffer.from(
1706
- html
1707
- ),
1708
- {
1709
- "Cache-Control":
1710
- "no-store",
1711
- }
1712
- );
1713
- }
1714
-
1715
- function sendCompressedBody(
1716
- req: http.IncomingMessage,
1717
- res: http.ServerResponse,
1718
- statusCode: number,
1719
- contentType: string,
1720
- rawBody: Buffer,
1721
- extraHeaders: Record<
1722
- string,
1723
- string
1724
- > = {}
1725
- ): void {
1726
- const encoding =
1727
- selectContentEncoding(
1728
- req,
1729
- contentType,
1730
- rawBody.length
1731
- );
1732
-
1733
- const body =
1734
- compressDynamicContent(
1735
- rawBody,
1736
- encoding
1737
- );
1738
-
1739
- const headers:
1740
- Record<string, string | number> = {
1741
- "Content-Type":
1742
- contentType,
1743
- "Content-Length":
1744
- body.length,
1745
- ...extraHeaders,
1746
- };
1747
-
1748
- if (
1749
- isCompressibleContentType(
1750
- contentType
1751
- )
1752
- ) {
1753
- headers.Vary =
1754
- appendVary(
1755
- typeof headers.Vary ===
1756
- "string"
1757
- ? headers.Vary
1758
- : undefined,
1759
- "Accept-Encoding"
1760
- );
1761
- }
1762
-
1763
- if (encoding) {
1764
- headers[
1765
- "Content-Encoding"
1766
- ] =
1767
- encoding;
1768
- }
1769
-
1770
- res.writeHead(
1771
- statusCode,
1772
- headers
1773
- );
1774
-
1775
- if (
1776
- req.method === "HEAD"
1777
- ) {
1778
- res.end();
1779
- return;
1780
- }
1781
-
1782
- res.end(
1783
- body
1784
- );
1785
- }
1786
-
1787
- function sendSimpleNotFound(
1788
- req: http.IncomingMessage,
1789
- res: http.ServerResponse
1790
- ): void {
1791
- sendHtml(
1792
- req,
1793
- res,
1794
- 404,
1795
- genericNotFoundDocument()
1796
- );
1797
- }
1798
-
1799
- function sendInternalServerError(
1800
- req: http.IncomingMessage,
1801
- res: http.ServerResponse,
1802
- error: unknown
1803
- ): void {
1804
- const digest =
1805
- createErrorDigest(
1806
- error
1807
- );
1808
-
1809
- sendHtml(
1810
- req,
1811
- res,
1812
- 500,
1813
- genericErrorDocument(
1814
- digest
1815
- )
1816
- );
1817
- }
1818
-
1819
- function genericNotFoundDocument(): string {
1820
- return "<!DOCTYPE html><html><body><main><h1>404</h1><p>Page Not Found</p></main></body></html>";
1821
- }
1822
-
1823
- function genericErrorDocument(
1824
- digest: string
1825
- ): string {
1826
- return `<!DOCTYPE html><html><body><main><h1>500</h1><p>Internal Server Error</p><p>Error ID: ${escapeHtml(
1827
- digest
1828
- )}</p></main></body></html>`;
1829
- }
1830
-
1831
- function createVariantEtag(
1832
- etag: string,
1833
- encoding: "br" | "gzip" | null
1834
- ): string {
1835
- if (!encoding) {
1836
- return etag;
1837
- }
1838
-
1839
- const value =
1840
- etag.replace(
1841
- /^W\//,
1842
- ""
1843
- );
1844
-
1845
- const unquoted =
1846
- value.startsWith('"') &&
1847
- value.endsWith('"')
1848
- ? value.slice(
1849
- 1,
1850
- -1
1851
- )
1852
- : value;
1853
-
1854
- return `"${unquoted}-${encoding}"`;
1855
- }
1856
-
1857
- function serializeFrameworkData(
1858
- value: unknown
1859
- ): string {
1860
- return JSON.stringify(
1861
- value
1862
- )
1863
- .replace(
1864
- /</g,
1865
- "\\u003c"
1866
- )
1867
- .replace(
1868
- /\u2028/g,
1869
- "\\u2028"
1870
- )
1871
- .replace(
1872
- /\u2029/g,
1873
- "\\u2029"
1874
- );
1875
- }
1876
-
1877
- function normalizePathname(
1878
- pathname: string
1879
- ): string {
1880
- if (
1881
- pathname.length > 1 &&
1882
- pathname.endsWith(
1883
- "/"
1884
- )
1885
- ) {
1886
- return pathname.slice(
1887
- 0,
1888
- -1
1889
- );
1890
- }
1891
-
1892
- return pathname || "/";
1893
- }
1894
-
1895
- function isPathInside(
1896
- root: string,
1897
- candidate: string
1898
- ): boolean {
1899
- const relative =
1900
- path.relative(
1901
- root,
1902
- candidate
1903
- );
1904
-
1905
- return (
1906
- relative === "" ||
1907
- (
1908
- relative !== ".." &&
1909
- !relative.startsWith(
1910
- `..${path.sep}`
1911
- ) &&
1912
- !path.isAbsolute(
1913
- relative
1914
- )
1915
- )
1916
- );
1917
- }
1918
-
1919
- function escapeHtml(
1920
- value: string
1921
- ): string {
1922
- return value
1923
- .replaceAll(
1924
- "&",
1925
- "&amp;"
1926
- )
1927
- .replaceAll(
1928
- "<",
1929
- "&lt;"
1930
- )
1931
- .replaceAll(
1932
- ">",
1933
- "&gt;"
1934
- )
1935
- .replaceAll(
1936
- '"',
1937
- "&quot;"
1938
- )
1939
- .replaceAll(
1940
- "'",
1941
- "&#039;"
1942
- );
1943
- }
1944
-
1945
- function escapeHtmlAttribute(
1946
- value: string
1947
- ): string {
1948
- return escapeHtml(
1949
- value
1950
- );
1951
- }