@chidchanun/bcp 0.1.6 → 0.1.7

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,1043 @@
1
+ import {
2
+ AsyncLocalStorage,
3
+ } from "node:async_hooks";
4
+ import {
5
+ randomUUID,
6
+ } from "node:crypto";
7
+ import {
8
+ isIP,
9
+ } from "node:net";
10
+
11
+ /*
12
+ * =====================================
13
+ * Public Types
14
+ * =====================================
15
+ */
16
+
17
+ export interface RequestCookie {
18
+ name: string;
19
+ value: string;
20
+ }
21
+
22
+ export type CookieSameSite =
23
+ | "lax"
24
+ | "strict"
25
+ | "none";
26
+
27
+ export interface ResponseCookieOptions {
28
+ domain?: string;
29
+
30
+ path?: string;
31
+
32
+ expires?:
33
+ Date;
34
+
35
+ maxAge?:
36
+ number;
37
+
38
+ httpOnly?:
39
+ boolean;
40
+
41
+ secure?:
42
+ boolean;
43
+
44
+ sameSite?:
45
+ CookieSameSite;
46
+ }
47
+
48
+ export interface ResponseCookie
49
+ extends
50
+ RequestCookie,
51
+ ResponseCookieOptions {}
52
+
53
+ export interface RequestCookieStore {
54
+ get(
55
+ name: string
56
+ ): RequestCookie | undefined;
57
+
58
+ getAll(
59
+ name?: string
60
+ ): RequestCookie[];
61
+
62
+ has(
63
+ name: string
64
+ ): boolean;
65
+
66
+ set(
67
+ name: string,
68
+ value: string,
69
+ options?:
70
+ ResponseCookieOptions
71
+ ): void;
72
+
73
+ set(
74
+ cookie:
75
+ ResponseCookie
76
+ ): void;
77
+
78
+ delete(
79
+ name: string,
80
+ options?: {
81
+ domain?: string;
82
+ path?: string;
83
+ }
84
+ ): void;
85
+ }
86
+
87
+ export interface ClientIpOptions {
88
+ trustProxy?:
89
+ boolean;
90
+ }
91
+
92
+ interface BcpRequestContext {
93
+ url:
94
+ URL;
95
+
96
+ method:
97
+ string;
98
+
99
+ requestId:
100
+ string;
101
+
102
+ remoteAddress:
103
+ string | null;
104
+
105
+ headers:
106
+ Headers;
107
+
108
+ cookies:
109
+ BcpRequestCookieStore;
110
+ }
111
+
112
+ interface RequestContextOptions {
113
+ remoteAddress?:
114
+ string | null;
115
+ }
116
+
117
+ const requestStorage =
118
+ new AsyncLocalStorage<
119
+ BcpRequestContext
120
+ >();
121
+
122
+ export async function requestUrl():
123
+ Promise<URL> {
124
+ const context =
125
+ getRequestContext();
126
+
127
+ return new URL(
128
+ context.url.href
129
+ );
130
+ }
131
+
132
+ export async function requestMethod():
133
+ Promise<string> {
134
+ return getRequestContext()
135
+ .method;
136
+ }
137
+
138
+ export async function requestId():
139
+ Promise<string> {
140
+ return getRequestContext()
141
+ .requestId;
142
+ }
143
+
144
+ export async function bearerToken():
145
+ Promise<string | null> {
146
+ const authorization =
147
+ getRequestContext()
148
+ .headers.get(
149
+ "authorization"
150
+ );
151
+
152
+ if (!authorization) {
153
+ return null;
154
+ }
155
+
156
+ const match =
157
+ /^Bearer\s+(.+)$/i.exec(
158
+ authorization.trim()
159
+ );
160
+
161
+ if (!match) {
162
+ return null;
163
+ }
164
+
165
+ const token =
166
+ match[1].trim();
167
+
168
+ return token.length > 0
169
+ ? token
170
+ : null;
171
+ }
172
+
173
+ export async function clientIp(
174
+ options:
175
+ ClientIpOptions = {}
176
+ ): Promise<string | null> {
177
+ const context =
178
+ getRequestContext();
179
+
180
+ if (
181
+ options.trustProxy ===
182
+ true
183
+ ) {
184
+ const forwarded =
185
+ parseForwardedFor(
186
+ context.headers
187
+ .get(
188
+ "forwarded"
189
+ )
190
+ ) ??
191
+ parseXForwardedFor(
192
+ context.headers
193
+ .get(
194
+ "x-forwarded-for"
195
+ )
196
+ ) ??
197
+ normalizeIpAddress(
198
+ context.headers
199
+ .get(
200
+ "x-real-ip"
201
+ )
202
+ );
203
+
204
+ if (forwarded) {
205
+ return forwarded;
206
+ }
207
+ }
208
+
209
+ return normalizeIpAddress(
210
+ context.remoteAddress
211
+ );
212
+ }
213
+
214
+ export async function headers():
215
+ Promise<Headers> {
216
+ const context =
217
+ getRequestContext();
218
+
219
+ return new Headers(
220
+ context.headers
221
+ );
222
+ }
223
+
224
+ export async function cookies():
225
+ Promise<RequestCookieStore> {
226
+ const context =
227
+ getRequestContext();
228
+
229
+ return context.cookies;
230
+ }
231
+
232
+ export function runWithRequestContext<
233
+ T
234
+ >(
235
+ request: Request,
236
+ callback: () => T,
237
+ options:
238
+ RequestContextOptions = {}
239
+ ): T {
240
+ const requestHeaders =
241
+ new Headers(
242
+ request.headers
243
+ );
244
+
245
+ const cookieStore =
246
+ new BcpRequestCookieStore(
247
+ requestHeaders.get(
248
+ "cookie"
249
+ )
250
+ );
251
+
252
+ const incomingRequestId =
253
+ normalizeRequestId(
254
+ requestHeaders.get(
255
+ "x-request-id"
256
+ )
257
+ );
258
+
259
+ return requestStorage.run(
260
+ {
261
+ url:
262
+ new URL(
263
+ request.url
264
+ ),
265
+
266
+ method:
267
+ request.method
268
+ .toUpperCase(),
269
+
270
+ requestId:
271
+ incomingRequestId ??
272
+ randomUUID(),
273
+
274
+ remoteAddress:
275
+ options.remoteAddress ??
276
+ null,
277
+
278
+ headers:
279
+ requestHeaders,
280
+
281
+ cookies:
282
+ cookieStore,
283
+ },
284
+ callback
285
+ );
286
+ }
287
+
288
+ export function applyResponseCookies(
289
+ response: Response
290
+ ): Response {
291
+ const context =
292
+ getRequestContext();
293
+
294
+ const setCookies =
295
+ context.cookies
296
+ .getPendingSetCookies();
297
+
298
+ if (
299
+ setCookies.length ===
300
+ 0
301
+ ) {
302
+ return response;
303
+ }
304
+
305
+ const responseHeaders =
306
+ new Headers(
307
+ response.headers
308
+ );
309
+
310
+ for (
311
+ const cookie
312
+ of setCookies
313
+ ) {
314
+ responseHeaders.append(
315
+ "set-cookie",
316
+ cookie
317
+ );
318
+ }
319
+
320
+ return new Response(
321
+ response.body,
322
+ {
323
+ status:
324
+ response.status,
325
+
326
+ statusText:
327
+ response.statusText,
328
+
329
+ headers:
330
+ responseHeaders,
331
+ }
332
+ );
333
+ }
334
+
335
+ function getRequestContext():
336
+ BcpRequestContext {
337
+ const context =
338
+ requestStorage.getStore();
339
+
340
+ if (!context) {
341
+ throw new Error(
342
+ "BCP Framework: server request APIs can only be used while handling a request."
343
+ );
344
+ }
345
+
346
+ return context;
347
+ }
348
+
349
+ function normalizeRequestId(
350
+ value:
351
+ string | null
352
+ ): string | null {
353
+ if (!value) {
354
+ return null;
355
+ }
356
+
357
+ const normalized =
358
+ value.trim();
359
+
360
+ if (
361
+ normalized.length ===
362
+ 0 ||
363
+ normalized.length >
364
+ 256 ||
365
+ /[\u0000-\u001F\u007F]/.test(
366
+ normalized
367
+ )
368
+ ) {
369
+ return null;
370
+ }
371
+
372
+ return normalized;
373
+ }
374
+
375
+ function parseXForwardedFor(
376
+ value:
377
+ string | null
378
+ ): string | null {
379
+ if (!value) {
380
+ return null;
381
+ }
382
+
383
+ for (
384
+ const part
385
+ of value.split(",")
386
+ ) {
387
+ const candidate =
388
+ normalizeIpAddress(
389
+ part
390
+ );
391
+
392
+ if (candidate) {
393
+ return candidate;
394
+ }
395
+ }
396
+
397
+ return null;
398
+ }
399
+
400
+ function parseForwardedFor(
401
+ value:
402
+ string | null
403
+ ): string | null {
404
+ if (!value) {
405
+ return null;
406
+ }
407
+
408
+ for (
409
+ const forwardedElement
410
+ of value.split(",")
411
+ ) {
412
+ for (
413
+ const parameter
414
+ of forwardedElement
415
+ .split(";")
416
+ ) {
417
+ const separator =
418
+ parameter.indexOf("=");
419
+
420
+ if (
421
+ separator <= 0 ||
422
+ parameter
423
+ .slice(
424
+ 0,
425
+ separator
426
+ )
427
+ .trim()
428
+ .toLowerCase() !==
429
+ "for"
430
+ ) {
431
+ continue;
432
+ }
433
+
434
+ let candidate =
435
+ parameter
436
+ .slice(
437
+ separator + 1
438
+ )
439
+ .trim();
440
+
441
+ if (
442
+ candidate.startsWith(
443
+ "\""
444
+ ) &&
445
+ candidate.endsWith(
446
+ "\""
447
+ )
448
+ ) {
449
+ candidate =
450
+ candidate.slice(
451
+ 1,
452
+ -1
453
+ );
454
+ }
455
+
456
+ const normalized =
457
+ normalizeIpAddress(
458
+ candidate
459
+ );
460
+
461
+ if (normalized) {
462
+ return normalized;
463
+ }
464
+ }
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ function normalizeIpAddress(
471
+ value:
472
+ string | null
473
+ ): string | null {
474
+ if (!value) {
475
+ return null;
476
+ }
477
+
478
+ let normalized =
479
+ value.trim();
480
+
481
+ if (
482
+ normalized.length ===
483
+ 0 ||
484
+ normalized.toLowerCase() ===
485
+ "unknown" ||
486
+ normalized.startsWith(
487
+ "_"
488
+ )
489
+ ) {
490
+ return null;
491
+ }
492
+
493
+ if (
494
+ normalized.startsWith(
495
+ "["
496
+ )
497
+ ) {
498
+ const closingBracket =
499
+ normalized.indexOf(
500
+ "]"
501
+ );
502
+
503
+ if (
504
+ closingBracket > 0
505
+ ) {
506
+ normalized =
507
+ normalized.slice(
508
+ 1,
509
+ closingBracket
510
+ );
511
+ }
512
+ } else {
513
+ const ipv4WithPort =
514
+ /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(
515
+ normalized
516
+ );
517
+
518
+ if (ipv4WithPort) {
519
+ normalized =
520
+ ipv4WithPort[1];
521
+ }
522
+ }
523
+
524
+ if (
525
+ normalized.startsWith(
526
+ "::ffff:"
527
+ )
528
+ ) {
529
+ normalized =
530
+ normalized.slice(
531
+ "::ffff:".length
532
+ );
533
+ }
534
+
535
+ return isIP(
536
+ normalized
537
+ ) > 0
538
+ ? normalized
539
+ : null;
540
+ }
541
+
542
+ class BcpRequestCookieStore
543
+ implements RequestCookieStore {
544
+ private readonly items:
545
+ RequestCookie[];
546
+
547
+ private readonly pendingSetCookies:
548
+ string[] = [];
549
+
550
+ constructor(
551
+ cookieHeader:
552
+ string | null
553
+ ) {
554
+ this.items =
555
+ parseCookieHeader(
556
+ cookieHeader
557
+ );
558
+ }
559
+
560
+ get(
561
+ name: string
562
+ ):
563
+ RequestCookie |
564
+ undefined {
565
+ const cookie =
566
+ this.items.find(
567
+ (item) =>
568
+ item.name ===
569
+ name
570
+ );
571
+
572
+ if (!cookie) {
573
+ return undefined;
574
+ }
575
+
576
+ return {
577
+ ...cookie,
578
+ };
579
+ }
580
+
581
+ getAll(
582
+ name?: string
583
+ ): RequestCookie[] {
584
+ const result =
585
+ name === undefined
586
+ ? this.items
587
+ : this.items.filter(
588
+ (item) =>
589
+ item.name ===
590
+ name
591
+ );
592
+
593
+ return result.map(
594
+ (cookie) => ({
595
+ ...cookie,
596
+ })
597
+ );
598
+ }
599
+
600
+ has(
601
+ name: string
602
+ ): boolean {
603
+ return this.items.some(
604
+ (cookie) =>
605
+ cookie.name ===
606
+ name
607
+ );
608
+ }
609
+
610
+ set(
611
+ nameOrCookie:
612
+ string |
613
+ ResponseCookie,
614
+
615
+ value?: string,
616
+
617
+ options:
618
+ ResponseCookieOptions =
619
+ {}
620
+ ): void {
621
+ let cookie:
622
+ ResponseCookie;
623
+
624
+ if (
625
+ typeof nameOrCookie ===
626
+ "string"
627
+ ) {
628
+ if (
629
+ value ===
630
+ undefined
631
+ ) {
632
+ throw new Error(
633
+ "BCP Framework: cookie value is required."
634
+ );
635
+ }
636
+
637
+ cookie = {
638
+ name:
639
+ nameOrCookie,
640
+
641
+ value,
642
+
643
+ ...options,
644
+ };
645
+ } else {
646
+ cookie = {
647
+ ...nameOrCookie,
648
+ };
649
+ }
650
+
651
+ validateCookieName(
652
+ cookie.name
653
+ );
654
+
655
+ validateCookieValue(
656
+ cookie.value
657
+ );
658
+
659
+ this.upsertLocalCookie(
660
+ cookie.name,
661
+ cookie.value
662
+ );
663
+
664
+ this.pendingSetCookies.push(
665
+ serializeCookie(
666
+ cookie
667
+ )
668
+ );
669
+ }
670
+
671
+ delete(
672
+ name: string,
673
+ options: {
674
+ domain?: string;
675
+ path?: string;
676
+ } = {}
677
+ ): void {
678
+ validateCookieName(
679
+ name
680
+ );
681
+
682
+ for (
683
+ let index =
684
+ this.items.length - 1;
685
+
686
+ index >= 0;
687
+
688
+ index--
689
+ ) {
690
+ if (
691
+ this.items[index]
692
+ .name ===
693
+ name
694
+ ) {
695
+ this.items.splice(
696
+ index,
697
+ 1
698
+ );
699
+ }
700
+ }
701
+
702
+ this.pendingSetCookies.push(
703
+ serializeCookie({
704
+ name,
705
+
706
+ value:
707
+ "",
708
+
709
+ path:
710
+ options.path ??
711
+ "/",
712
+
713
+ domain:
714
+ options.domain,
715
+
716
+ expires:
717
+ new Date(0),
718
+
719
+ maxAge:
720
+ 0,
721
+ })
722
+ );
723
+ }
724
+
725
+ getPendingSetCookies():
726
+ string[] {
727
+ return [
728
+ ...this.pendingSetCookies,
729
+ ];
730
+ }
731
+
732
+ private upsertLocalCookie(
733
+ name: string,
734
+ value: string
735
+ ): void {
736
+ const existing =
737
+ this.items.find(
738
+ (cookie) =>
739
+ cookie.name ===
740
+ name
741
+ );
742
+
743
+ if (existing) {
744
+ existing.value =
745
+ value;
746
+
747
+ return;
748
+ }
749
+
750
+ this.items.push({
751
+ name,
752
+ value,
753
+ });
754
+ }
755
+ }
756
+
757
+ function parseCookieHeader(
758
+ header:
759
+ string | null
760
+ ): RequestCookie[] {
761
+ if (!header) {
762
+ return [];
763
+ }
764
+
765
+ const result:
766
+ RequestCookie[] = [];
767
+
768
+ for (
769
+ const segment
770
+ of header.split(";")
771
+ ) {
772
+ const separator =
773
+ segment.indexOf("=");
774
+
775
+ if (
776
+ separator <= 0
777
+ ) {
778
+ continue;
779
+ }
780
+
781
+ const name =
782
+ segment
783
+ .slice(
784
+ 0,
785
+ separator
786
+ )
787
+ .trim();
788
+
789
+ if (!name) {
790
+ continue;
791
+ }
792
+
793
+ const rawValue =
794
+ segment
795
+ .slice(
796
+ separator + 1
797
+ )
798
+ .trim();
799
+
800
+ result.push({
801
+ name,
802
+
803
+ value:
804
+ decodeCookieValue(
805
+ rawValue
806
+ ),
807
+ });
808
+ }
809
+
810
+ return result;
811
+ }
812
+
813
+ function serializeCookie(
814
+ cookie:
815
+ ResponseCookie
816
+ ): string {
817
+ validateCookieName(
818
+ cookie.name
819
+ );
820
+
821
+ validateCookieValue(
822
+ cookie.value
823
+ );
824
+
825
+ const parts:
826
+ string[] = [
827
+ `${cookie.name}=${encodeURIComponent(
828
+ cookie.value
829
+ )}`,
830
+ ];
831
+
832
+ if (
833
+ cookie.domain !==
834
+ undefined
835
+ ) {
836
+ validateCookieAttribute(
837
+ cookie.domain,
838
+ "domain"
839
+ );
840
+
841
+ parts.push(
842
+ `Domain=${cookie.domain}`
843
+ );
844
+ }
845
+
846
+ if (
847
+ cookie.path !==
848
+ undefined
849
+ ) {
850
+ validateCookieAttribute(
851
+ cookie.path,
852
+ "path"
853
+ );
854
+
855
+ parts.push(
856
+ `Path=${cookie.path}`
857
+ );
858
+ }
859
+
860
+ if (
861
+ cookie.expires !==
862
+ undefined
863
+ ) {
864
+ if (
865
+ !(
866
+ cookie.expires
867
+ instanceof Date
868
+ ) ||
869
+ Number.isNaN(
870
+ cookie.expires.getTime()
871
+ )
872
+ ) {
873
+ throw new Error(
874
+ "BCP Framework: cookie expires must be a valid Date."
875
+ );
876
+ }
877
+
878
+ parts.push(
879
+ `Expires=${cookie.expires.toUTCString()}`
880
+ );
881
+ }
882
+
883
+ if (
884
+ cookie.maxAge !==
885
+ undefined
886
+ ) {
887
+ if (
888
+ !Number.isFinite(
889
+ cookie.maxAge
890
+ )
891
+ ) {
892
+ throw new Error(
893
+ "BCP Framework: cookie maxAge must be a finite number."
894
+ );
895
+ }
896
+
897
+ parts.push(
898
+ `Max-Age=${Math.trunc(
899
+ cookie.maxAge
900
+ )}`
901
+ );
902
+ }
903
+
904
+ if (
905
+ cookie.httpOnly
906
+ ) {
907
+ parts.push(
908
+ "HttpOnly"
909
+ );
910
+ }
911
+
912
+ if (
913
+ cookie.secure
914
+ ) {
915
+ parts.push(
916
+ "Secure"
917
+ );
918
+ }
919
+
920
+ if (
921
+ cookie.sameSite !==
922
+ undefined
923
+ ) {
924
+ parts.push(
925
+ `SameSite=${formatSameSite(
926
+ cookie.sameSite
927
+ )}`
928
+ );
929
+ }
930
+
931
+ return parts.join(
932
+ "; "
933
+ );
934
+ }
935
+
936
+ function validateCookieName(
937
+ name: string
938
+ ): void {
939
+ if (
940
+ typeof name !==
941
+ "string" ||
942
+ name.length ===
943
+ 0
944
+ ) {
945
+ throw new Error(
946
+ "BCP Framework: cookie name must be a non-empty string."
947
+ );
948
+ }
949
+
950
+ if (
951
+ !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(
952
+ name
953
+ )
954
+ ) {
955
+ throw new Error(
956
+ `BCP Framework: invalid cookie name "${name}".`
957
+ );
958
+ }
959
+ }
960
+
961
+ function validateCookieValue(
962
+ value: string
963
+ ): void {
964
+ if (
965
+ typeof value !==
966
+ "string"
967
+ ) {
968
+ throw new Error(
969
+ "BCP Framework: cookie value must be a string."
970
+ );
971
+ }
972
+
973
+ if (
974
+ /[\u0000-\u001F\u007F]/.test(
975
+ value
976
+ )
977
+ ) {
978
+ throw new Error(
979
+ "BCP Framework: cookie value contains invalid control characters."
980
+ );
981
+ }
982
+ }
983
+
984
+ function validateCookieAttribute(
985
+ value: string,
986
+ label: string
987
+ ): void {
988
+ if (
989
+ typeof value !==
990
+ "string" ||
991
+ value.length ===
992
+ 0
993
+ ) {
994
+ throw new Error(
995
+ `BCP Framework: cookie ${label} must be a non-empty string.`
996
+ );
997
+ }
998
+
999
+ if (
1000
+ /[\r\n;]/.test(
1001
+ value
1002
+ )
1003
+ ) {
1004
+ throw new Error(
1005
+ `BCP Framework: invalid cookie ${label}.`
1006
+ );
1007
+ }
1008
+ }
1009
+
1010
+ function formatSameSite(
1011
+ value:
1012
+ CookieSameSite
1013
+ ): string {
1014
+ switch (value) {
1015
+ case "strict":
1016
+ return "Strict";
1017
+
1018
+ case "none":
1019
+ return "None";
1020
+
1021
+ case "lax":
1022
+ return "Lax";
1023
+
1024
+ default:
1025
+ throw new Error(
1026
+ `BCP Framework: invalid SameSite value "${String(
1027
+ value
1028
+ )}".`
1029
+ );
1030
+ }
1031
+ }
1032
+
1033
+ function decodeCookieValue(
1034
+ value: string
1035
+ ): string {
1036
+ try {
1037
+ return decodeURIComponent(
1038
+ value
1039
+ );
1040
+ } catch {
1041
+ return value;
1042
+ }
1043
+ }