@onebun/core 0.1.24 → 0.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.24",
3
+ "version": "0.2.1",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -41,17 +41,17 @@
41
41
  "dependencies": {
42
42
  "effect": "^3.13.10",
43
43
  "arktype": "^2.0.0",
44
- "@onebun/logger": "^0.1.7",
45
- "@onebun/envs": "^0.1.4",
46
- "@onebun/metrics": "^0.1.6",
47
- "@onebun/requests": "^0.1.3",
48
- "@onebun/trace": "^0.1.4"
44
+ "@onebun/logger": "^0.2.0",
45
+ "@onebun/envs": "^0.2.0",
46
+ "@onebun/metrics": "^0.2.0",
47
+ "@onebun/requests": "^0.2.0",
48
+ "@onebun/trace": "^0.2.0"
49
49
  },
50
50
  "devDependencies": {
51
- "bun-types": "1.2.2",
51
+ "bun-types": "^1.3.8",
52
52
  "testcontainers": "^11.7.1"
53
53
  },
54
54
  "engines": {
55
- "bun": "1.2.2"
55
+ "bun": ">=1.2.12"
56
56
  }
57
57
  }
@@ -21,6 +21,8 @@ import {
21
21
  Query,
22
22
  Body,
23
23
  Header,
24
+ Cookie,
25
+ Req,
24
26
  } from '../decorators/decorators';
25
27
  import { Controller as BaseController } from '../module/controller';
26
28
  import { makeMockLoggerLayer } from '../testing/test-utils';
@@ -38,6 +40,99 @@ function createTestApp(
38
40
  });
39
41
  }
40
42
 
43
+ /**
44
+ * Match a request path against a route pattern with :param support.
45
+ * Returns extracted params or null if no match.
46
+ */
47
+ function matchRoutePattern(pattern: string, path: string): Record<string, string> | null {
48
+ if (pattern === path) {
49
+ return {};
50
+ }
51
+ if (!pattern.includes(':')) {
52
+ return null;
53
+ }
54
+
55
+ const patternParts = pattern.split('/');
56
+ const pathParts = path.split('/');
57
+
58
+ if (patternParts.length !== pathParts.length) {
59
+ return null;
60
+ }
61
+
62
+ const params: Record<string, string> = {};
63
+ for (let i = 0; i < patternParts.length; i++) {
64
+ if (patternParts[i].startsWith(':')) {
65
+ params[patternParts[i].slice(1)] = pathParts[i];
66
+ } else if (patternParts[i] !== pathParts[i]) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ return params;
72
+ }
73
+
74
+ /**
75
+ * Create a mock Bun.serve that captures routes and emulates Bun route matching.
76
+ * The mock's fetchHandler resolves routes by pattern matching and creates
77
+ * BunRequest-like objects with params and cookies.
78
+ */
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ function createRoutesAwareMock(mockServer: any) {
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ return mock((options: any) => {
83
+ const routes = options.routes || {};
84
+ const fetchFallback = options.fetch;
85
+
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ (mockServer as any).fetchHandler = async (request: Request) => {
88
+ const url = new URL(request.url);
89
+ const path = url.pathname;
90
+ const method = request.method;
91
+
92
+ // Try to find matching route (iterate all registered patterns)
93
+ for (const [pattern, handlers] of Object.entries(routes)) {
94
+ const params = matchRoutePattern(pattern, path);
95
+ if (params !== null) {
96
+ const routeHandlers = typeof handlers === 'function'
97
+ ? { [method]: handlers as Function }
98
+ : handlers as Record<string, Function>;
99
+ const handler = routeHandlers[method];
100
+ if (handler) {
101
+ // Create BunRequest-like object with params and cookies
102
+ // Parse Cookie header into a Map to emulate CookieMap
103
+ const cookieHeader = request.headers.get('cookie') || '';
104
+ const cookieMap = new Map<string, string>();
105
+ if (cookieHeader) {
106
+ for (const pair of cookieHeader.split(';')) {
107
+ const [key, ...rest] = pair.split('=');
108
+ if (key) {
109
+ cookieMap.set(key.trim(), rest.join('=').trim());
110
+ }
111
+ }
112
+ }
113
+
114
+ const bunReq = Object.assign(request, {
115
+ params,
116
+ cookies: cookieMap,
117
+ });
118
+
119
+ return handler(bunReq, mockServer);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Fall through to fetch handler
125
+ if (fetchFallback) {
126
+ return fetchFallback(request, mockServer);
127
+ }
128
+
129
+ return new Response('Not Found', { status: 404 });
130
+ };
131
+
132
+ return mockServer;
133
+ });
134
+ }
135
+
41
136
  describe('OneBunApplication', () => {
42
137
  beforeEach(() => {
43
138
  register.clear();
@@ -985,12 +1080,7 @@ describe('OneBunApplication', () => {
985
1080
 
986
1081
  originalServe = Bun.serve;
987
1082
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
988
- (Bun as any).serve = mock((options: any) => {
989
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
990
- (mockServer as any).fetchHandler = options.fetch;
991
-
992
- return mockServer;
993
- });
1083
+ (Bun as any).serve = createRoutesAwareMock(mockServer);
994
1084
  });
995
1085
 
996
1086
  afterEach(() => {
@@ -1052,13 +1142,7 @@ describe('OneBunApplication', () => {
1052
1142
 
1053
1143
  originalServe = Bun.serve;
1054
1144
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1055
- (Bun as any).serve = mock((options: any) => {
1056
- // Store the fetch handler for testing
1057
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1058
- (mockServer as any).fetchHandler = options.fetch;
1059
-
1060
- return mockServer;
1061
- });
1145
+ (Bun as any).serve = createRoutesAwareMock(mockServer);
1062
1146
  });
1063
1147
 
1064
1148
  afterEach(() => {
@@ -2460,12 +2544,7 @@ describe('OneBunApplication', () => {
2460
2544
 
2461
2545
  originalServe = Bun.serve;
2462
2546
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2463
- (Bun as any).serve = mock((options: any) => {
2464
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2465
- (mockServer as any).fetchHandler = options.fetch;
2466
-
2467
- return mockServer;
2468
- });
2547
+ (Bun as any).serve = createRoutesAwareMock(mockServer);
2469
2548
  });
2470
2549
 
2471
2550
  afterEach(() => {
@@ -2546,6 +2625,400 @@ describe('OneBunApplication', () => {
2546
2625
  });
2547
2626
  });
2548
2627
 
2628
+ describe('Cookies, Headers, and @Req()', () => {
2629
+ let originalServe: typeof Bun.serve;
2630
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2631
+ let mockServer: any;
2632
+
2633
+ beforeEach(() => {
2634
+ register.clear();
2635
+
2636
+ mockServer = {
2637
+ stop: mock(),
2638
+ hostname: 'localhost',
2639
+ port: 3000,
2640
+ };
2641
+
2642
+ originalServe = Bun.serve;
2643
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2644
+ (Bun as any).serve = createRoutesAwareMock(mockServer);
2645
+ });
2646
+
2647
+ afterEach(() => {
2648
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2649
+ (Bun as any).serve = originalServe;
2650
+ });
2651
+
2652
+ test('should extract cookie value with @Cookie decorator', async () => {
2653
+ @Controller('/api')
2654
+ class ApiController extends BaseController {
2655
+ @Get('/me')
2656
+ async getMe(@Cookie('session') session?: string) {
2657
+ return { session };
2658
+ }
2659
+ }
2660
+
2661
+ @Module({
2662
+ controllers: [ApiController],
2663
+ })
2664
+ class TestModule {}
2665
+
2666
+ const app = createTestApp(TestModule);
2667
+ await app.start();
2668
+
2669
+ const request = new Request('http://localhost:3000/api/me', {
2670
+ method: 'GET',
2671
+ headers: {
2672
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2673
+ 'Cookie': 'session=abc123; theme=dark',
2674
+ },
2675
+ });
2676
+
2677
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2678
+ const response = await (mockServer as any).fetchHandler(request);
2679
+ const body = await response.json();
2680
+
2681
+ expect(response.status).toBe(200);
2682
+ expect(body.result.session).toBe('abc123');
2683
+ });
2684
+
2685
+ test('should return undefined for missing cookie with @Cookie decorator', async () => {
2686
+ @Controller('/api')
2687
+ class ApiController extends BaseController {
2688
+ @Get('/me')
2689
+ async getMe(@Cookie('missing_cookie') value?: string) {
2690
+ return { value, isUndefined: value === undefined };
2691
+ }
2692
+ }
2693
+
2694
+ @Module({
2695
+ controllers: [ApiController],
2696
+ })
2697
+ class TestModule {}
2698
+
2699
+ const app = createTestApp(TestModule);
2700
+ await app.start();
2701
+
2702
+ const request = new Request('http://localhost:3000/api/me', {
2703
+ method: 'GET',
2704
+ });
2705
+
2706
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2707
+ const response = await (mockServer as any).fetchHandler(request);
2708
+ const body = await response.json();
2709
+
2710
+ expect(response.status).toBe(200);
2711
+ expect(body.result.value).toBeUndefined();
2712
+ expect(body.result.isUndefined).toBe(true);
2713
+ });
2714
+
2715
+ test('should return 500 when required cookie is missing', async () => {
2716
+ @Controller('/api')
2717
+ class ApiController extends BaseController {
2718
+ @Get('/auth')
2719
+ async auth(@Cookie('token', { required: true }) token: string) {
2720
+ return { token };
2721
+ }
2722
+ }
2723
+
2724
+ @Module({
2725
+ controllers: [ApiController],
2726
+ })
2727
+ class TestModule {}
2728
+
2729
+ const app = createTestApp(TestModule);
2730
+ await app.start();
2731
+
2732
+ // No cookies sent
2733
+ const request = new Request('http://localhost:3000/api/auth', {
2734
+ method: 'GET',
2735
+ });
2736
+
2737
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2738
+ const response = await (mockServer as any).fetchHandler(request);
2739
+
2740
+ expect(response.status).toBe(500);
2741
+ });
2742
+
2743
+ test('should extract multiple cookies with @Cookie decorator', async () => {
2744
+ @Controller('/api')
2745
+ class ApiController extends BaseController {
2746
+ @Get('/prefs')
2747
+ async prefs(
2748
+ @Cookie('theme') theme?: string,
2749
+ @Cookie('lang') lang?: string,
2750
+ @Cookie('session') session?: string,
2751
+ ) {
2752
+ return { theme, lang, session };
2753
+ }
2754
+ }
2755
+
2756
+ @Module({
2757
+ controllers: [ApiController],
2758
+ })
2759
+ class TestModule {}
2760
+
2761
+ const app = createTestApp(TestModule);
2762
+ await app.start();
2763
+
2764
+ const request = new Request('http://localhost:3000/api/prefs', {
2765
+ method: 'GET',
2766
+ headers: {
2767
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2768
+ 'Cookie': 'theme=dark; lang=en; session=xyz789',
2769
+ },
2770
+ });
2771
+
2772
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2773
+ const response = await (mockServer as any).fetchHandler(request);
2774
+ const body = await response.json();
2775
+
2776
+ expect(response.status).toBe(200);
2777
+ expect(body.result.theme).toBe('dark');
2778
+ expect(body.result.lang).toBe('en');
2779
+ expect(body.result.session).toBe('xyz789');
2780
+ });
2781
+
2782
+ test('should inject BunRequest-like object with @Req() that has cookies and params', async () => {
2783
+ @Controller('/api')
2784
+ class ApiController extends BaseController {
2785
+ @Get('/users/:id')
2786
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2787
+ async getUser(@Req() req: any) {
2788
+ return {
2789
+ hasCookies: typeof req.cookies?.get === 'function',
2790
+ hasParams: req.params !== undefined,
2791
+ paramId: req.params?.id,
2792
+ cookieSession: req.cookies?.get('session') ?? null,
2793
+ };
2794
+ }
2795
+ }
2796
+
2797
+ @Module({
2798
+ controllers: [ApiController],
2799
+ })
2800
+ class TestModule {}
2801
+
2802
+ const app = createTestApp(TestModule);
2803
+ await app.start();
2804
+
2805
+ const request = new Request('http://localhost:3000/api/users/42', {
2806
+ method: 'GET',
2807
+ headers: {
2808
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2809
+ 'Cookie': 'session=test-session',
2810
+ },
2811
+ });
2812
+
2813
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2814
+ const response = await (mockServer as any).fetchHandler(request);
2815
+ const body = await response.json();
2816
+
2817
+ expect(response.status).toBe(200);
2818
+ expect(body.result.hasCookies).toBe(true);
2819
+ expect(body.result.hasParams).toBe(true);
2820
+ expect(body.result.paramId).toBe('42');
2821
+ expect(body.result.cookieSession).toBe('test-session');
2822
+ });
2823
+
2824
+ test('should preserve custom headers in Response returned from handler', async () => {
2825
+ @Controller('/api')
2826
+ class ApiController extends BaseController {
2827
+ @Get('/custom-headers')
2828
+ async customHeaders() {
2829
+ return new Response(JSON.stringify({ message: 'ok' }), {
2830
+ status: 200,
2831
+ headers: {
2832
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2833
+ 'Content-Type': 'application/json',
2834
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2835
+ 'X-Custom-Header': 'custom-value',
2836
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2837
+ 'X-Request-ID': 'req-123',
2838
+ },
2839
+ });
2840
+ }
2841
+ }
2842
+
2843
+ @Module({
2844
+ controllers: [ApiController],
2845
+ })
2846
+ class TestModule {}
2847
+
2848
+ const app = createTestApp(TestModule);
2849
+ await app.start();
2850
+
2851
+ const request = new Request('http://localhost:3000/api/custom-headers', {
2852
+ method: 'GET',
2853
+ });
2854
+
2855
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2856
+ const response = await (mockServer as any).fetchHandler(request);
2857
+
2858
+ expect(response.status).toBe(200);
2859
+ expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
2860
+ expect(response.headers.get('X-Request-ID')).toBe('req-123');
2861
+ });
2862
+
2863
+ test('should preserve single Set-Cookie header in Response', async () => {
2864
+ @Controller('/api')
2865
+ class ApiController extends BaseController {
2866
+ @Get('/login')
2867
+ async login() {
2868
+ return new Response(JSON.stringify({ loggedIn: true }), {
2869
+ status: 200,
2870
+ headers: {
2871
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2872
+ 'Content-Type': 'application/json',
2873
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2874
+ 'Set-Cookie': 'session=abc123; Path=/; HttpOnly',
2875
+ },
2876
+ });
2877
+ }
2878
+ }
2879
+
2880
+ @Module({
2881
+ controllers: [ApiController],
2882
+ })
2883
+ class TestModule {}
2884
+
2885
+ const app = createTestApp(TestModule);
2886
+ await app.start();
2887
+
2888
+ const request = new Request('http://localhost:3000/api/login', {
2889
+ method: 'GET',
2890
+ });
2891
+
2892
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2893
+ const response = await (mockServer as any).fetchHandler(request);
2894
+
2895
+ expect(response.status).toBe(200);
2896
+ const setCookies = response.headers.getSetCookie();
2897
+ expect(setCookies.length).toBeGreaterThanOrEqual(1);
2898
+ expect(setCookies[0]).toContain('session=abc123');
2899
+ });
2900
+
2901
+ test('should preserve multiple Set-Cookie headers in Response', async () => {
2902
+ @Controller('/api')
2903
+ class ApiController extends BaseController {
2904
+ @Get('/multi-cookie')
2905
+ async multiCookie() {
2906
+ const headers = new Headers();
2907
+ headers.append('Content-Type', 'application/json');
2908
+ headers.append('Set-Cookie', 'session=abc; Path=/; HttpOnly');
2909
+ headers.append('Set-Cookie', 'theme=dark; Path=/');
2910
+ headers.append('Set-Cookie', 'lang=en; Path=/');
2911
+
2912
+ return new Response(JSON.stringify({ ok: true }), {
2913
+ status: 200,
2914
+ headers,
2915
+ });
2916
+ }
2917
+ }
2918
+
2919
+ @Module({
2920
+ controllers: [ApiController],
2921
+ })
2922
+ class TestModule {}
2923
+
2924
+ const app = createTestApp(TestModule);
2925
+ await app.start();
2926
+
2927
+ const request = new Request('http://localhost:3000/api/multi-cookie', {
2928
+ method: 'GET',
2929
+ });
2930
+
2931
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2932
+ const response = await (mockServer as any).fetchHandler(request);
2933
+
2934
+ expect(response.status).toBe(200);
2935
+ const setCookies = response.headers.getSetCookie();
2936
+ expect(setCookies.length).toBe(3);
2937
+ expect(setCookies).toContainEqual(expect.stringContaining('session=abc'));
2938
+ expect(setCookies).toContainEqual(expect.stringContaining('theme=dark'));
2939
+ expect(setCookies).toContainEqual(expect.stringContaining('lang=en'));
2940
+ });
2941
+
2942
+ test('should handle route params via req.params in @Req()', async () => {
2943
+ @Controller('/api')
2944
+ class ApiController extends BaseController {
2945
+ @Get('/items/:category/:id')
2946
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2947
+ async getItem(@Req() req: any) {
2948
+ return {
2949
+ category: req.params?.category,
2950
+ id: req.params?.id,
2951
+ };
2952
+ }
2953
+ }
2954
+
2955
+ @Module({
2956
+ controllers: [ApiController],
2957
+ })
2958
+ class TestModule {}
2959
+
2960
+ const app = createTestApp(TestModule);
2961
+ await app.start();
2962
+
2963
+ const request = new Request('http://localhost:3000/api/items/electronics/42', {
2964
+ method: 'GET',
2965
+ });
2966
+
2967
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2968
+ const response = await (mockServer as any).fetchHandler(request);
2969
+ const body = await response.json();
2970
+
2971
+ expect(response.status).toBe(200);
2972
+ expect(body.result.category).toBe('electronics');
2973
+ expect(body.result.id).toBe('42');
2974
+ });
2975
+
2976
+ test('should combine @Cookie, @Param, @Query, and @Header in same handler', async () => {
2977
+ @Controller('/api')
2978
+ class ApiController extends BaseController {
2979
+ @Get('/combined/:id')
2980
+ async combined(
2981
+ @Param('id') id: string,
2982
+ @Query('sort') sort: string,
2983
+ @Header('Authorization') auth: string,
2984
+ @Cookie('session') session?: string,
2985
+ ) {
2986
+ return {
2987
+ id: parseInt(id), sort, auth, session,
2988
+ };
2989
+ }
2990
+ }
2991
+
2992
+ @Module({
2993
+ controllers: [ApiController],
2994
+ })
2995
+ class TestModule {}
2996
+
2997
+ const app = createTestApp(TestModule);
2998
+ await app.start();
2999
+
3000
+ const request = new Request('http://localhost:3000/api/combined/99?sort=name', {
3001
+ method: 'GET',
3002
+ headers: {
3003
+ // eslint-disable-next-line @typescript-eslint/naming-convention
3004
+ 'Authorization': 'Bearer token456',
3005
+ // eslint-disable-next-line @typescript-eslint/naming-convention
3006
+ 'Cookie': 'session=sess789',
3007
+ },
3008
+ });
3009
+
3010
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3011
+ const response = await (mockServer as any).fetchHandler(request);
3012
+ const body = await response.json();
3013
+
3014
+ expect(response.status).toBe(200);
3015
+ expect(body.result.id).toBe(99);
3016
+ expect(body.result.sort).toBe('name');
3017
+ expect(body.result.auth).toBe('Bearer token456');
3018
+ expect(body.result.session).toBe('sess789');
3019
+ });
3020
+ });
3021
+
2549
3022
  describe('Graceful shutdown', () => {
2550
3023
  let originalServe: typeof Bun.serve;
2551
3024
  // eslint-disable-next-line @typescript-eslint/no-explicit-any