@dangao/bun-server 1.1.4 → 1.3.0

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,237 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { ParamBinder } from '../../src/controller/param-binder';
4
+ import {
5
+ QueryMap,
6
+ HeaderMap,
7
+ type QueryMapOptions,
8
+ type HeaderMapOptions,
9
+ } from '../../src/controller/decorators';
10
+ import { Context } from '../../src/core/context';
11
+
12
+ class QueryMapController {
13
+ public constructor(public readonly store: unknown[] = []) {}
14
+
15
+ public async handle(@QueryMap() query: Record<string, string | string[]>) {
16
+ this.store.push(query);
17
+ return query;
18
+ }
19
+
20
+ public async handleTransformed(
21
+ @QueryMap<{ name: string; age: number }>((input) => ({
22
+ name: (input.name as string) ?? '',
23
+ age: Number((input.age as string) ?? '0'),
24
+ }))
25
+ query: { name: string; age: number },
26
+ ) {
27
+ this.store.push(query);
28
+ return query;
29
+ }
30
+
31
+ public async handleValidated(
32
+ @QueryMap<{ foo: string }>(
33
+ ((input) => ({ foo: input.foo as string })) as QueryMapOptions<{ foo: string }>['transform'],
34
+ )
35
+ query: { foo: string },
36
+ ) {
37
+ if (!query.foo) {
38
+ throw new Error('validation failed');
39
+ }
40
+ this.store.push(query);
41
+ return query;
42
+ }
43
+ }
44
+
45
+ class HeaderMapController {
46
+ public constructor(public readonly store: unknown[] = []) {}
47
+
48
+ public async handle(
49
+ @HeaderMap() headers: Record<string, string | string[]>,
50
+ ) {
51
+ this.store.push(headers);
52
+ return headers;
53
+ }
54
+
55
+ public async handlePicked(
56
+ @HeaderMap({
57
+ normalize: true,
58
+ pick: ['x-custom', 'x-list'],
59
+ })
60
+ headers: Record<string, string | string[]>,
61
+ ) {
62
+ this.store.push(headers);
63
+ return headers;
64
+ }
65
+
66
+ public async handlePickedWithNormalizeFalse(
67
+ @HeaderMap({
68
+ normalize: false,
69
+ pick: ['X-Custom', 'X-List'], // 混合大小写
70
+ })
71
+ headers: Record<string, string | string[]>,
72
+ ) {
73
+ this.store.push(headers);
74
+ return headers;
75
+ }
76
+
77
+ public async handleTransformed(
78
+ @HeaderMap<{ token: string }>((input) => ({
79
+ token: (input.authorization as string) ?? '',
80
+ }))
81
+ headers: { token: string },
82
+ ) {
83
+ this.store.push(headers);
84
+ return headers;
85
+ }
86
+ }
87
+
88
+ function createContext(url: string, init?: RequestInit) {
89
+ return new Context(new Request(url, init));
90
+ }
91
+
92
+ describe('ParamBinder QueryMap', () => {
93
+ test('should aggregate query params into map with arrays', async () => {
94
+ const controller = new QueryMapController();
95
+ const ctx = createContext('http://localhost/api?q=1&q=2&name=alice');
96
+ const params = await ParamBinder.bind(
97
+ controller,
98
+ 'handle',
99
+ ctx,
100
+ );
101
+ expect(params[0]).toEqual({ q: ['1', '2'], name: 'alice' });
102
+ });
103
+
104
+ test('should transform query map', async () => {
105
+ const controller = new QueryMapController();
106
+ const ctx = createContext('http://localhost/api?name=alice&age=20');
107
+ const params = await ParamBinder.bind(
108
+ controller,
109
+ 'handleTransformed',
110
+ ctx,
111
+ );
112
+ expect(params[0]).toEqual({ name: 'alice', age: 20 });
113
+ });
114
+
115
+ test('should validate query map via user code', async () => {
116
+ const controller = new QueryMapController();
117
+ const ctx = createContext('http://localhost/api?foo=bar');
118
+ const params = await ParamBinder.bind(
119
+ controller,
120
+ 'handleValidated',
121
+ ctx,
122
+ );
123
+ expect(params[0]).toEqual({ foo: 'bar' });
124
+ });
125
+ });
126
+
127
+ describe('ParamBinder HeaderMap', () => {
128
+ test('should aggregate headers into map with optional array', async () => {
129
+ const controller = new HeaderMapController();
130
+ const ctx = createContext('http://localhost/api', {
131
+ headers: {
132
+ 'X-Token': 'abc',
133
+ 'X-List': 'a, b',
134
+ },
135
+ });
136
+ const params = await ParamBinder.bind(
137
+ controller,
138
+ 'handle',
139
+ ctx,
140
+ );
141
+ expect(params[0]).toMatchObject({
142
+ 'x-token': 'abc',
143
+ 'x-list': ['a', 'b'],
144
+ });
145
+ });
146
+
147
+ test('should pick and normalize headers', async () => {
148
+ const controller = new HeaderMapController();
149
+ const ctx = createContext('http://localhost/api', {
150
+ headers: {
151
+ 'X-Custom': 'val',
152
+ 'X-List': 'a, b',
153
+ 'Other': 'ignore',
154
+ },
155
+ });
156
+ const params = await ParamBinder.bind(
157
+ controller,
158
+ 'handlePicked',
159
+ ctx,
160
+ );
161
+ expect(params[0]).toEqual({
162
+ 'x-custom': 'val',
163
+ 'x-list': ['a', 'b'],
164
+ });
165
+ });
166
+
167
+ test('should pick headers with case-insensitive pick when normalize=true (default)', async () => {
168
+ const controller = new HeaderMapController();
169
+ const ctx = createContext('http://localhost/api', {
170
+ headers: {
171
+ 'X-CUSTOM': 'val',
172
+ 'X-LIST': 'a, b',
173
+ 'Other': 'ignore',
174
+ },
175
+ });
176
+ const params = await ParamBinder.bind(
177
+ controller,
178
+ 'handlePicked',
179
+ ctx,
180
+ );
181
+ expect(params[0]).toEqual({
182
+ 'x-custom': 'val',
183
+ 'x-list': ['a', 'b'],
184
+ });
185
+ });
186
+
187
+ test('should transform headers', async () => {
188
+ const controller = new HeaderMapController();
189
+ const ctx = createContext('http://localhost/api', {
190
+ headers: {
191
+ Authorization: ' Bearer token123 ',
192
+ },
193
+ });
194
+ const params = await ParamBinder.bind(
195
+ controller,
196
+ 'handleTransformed',
197
+ ctx,
198
+ );
199
+ expect(params[0]).toEqual({ token: 'Bearer token123' });
200
+ });
201
+
202
+ test('should trim single header value when no comma', async () => {
203
+ const controller = new HeaderMapController();
204
+ const ctx = createContext('http://localhost/api', {
205
+ headers: {
206
+ 'X-Single': ' abc ',
207
+ },
208
+ });
209
+ const params = await ParamBinder.bind(controller, 'handle', ctx);
210
+ expect(params[0]).toMatchObject({
211
+ 'x-single': 'abc',
212
+ });
213
+ });
214
+
215
+ test('should pick headers with mixed-case pick keys when normalize=false', async () => {
216
+ const controller = new HeaderMapController();
217
+ const ctx = createContext('http://localhost/api', {
218
+ headers: {
219
+ 'X-Custom': 'val',
220
+ 'X-List': 'a, b',
221
+ 'Other': 'ignore',
222
+ },
223
+ });
224
+ const params = await ParamBinder.bind(
225
+ controller,
226
+ 'handlePickedWithNormalizeFalse',
227
+ ctx,
228
+ );
229
+ // Headers API 总是返回小写的 key,所以即使 normalize=false,结果中的 key 也是小写
230
+ expect(params[0]).toEqual({
231
+ 'x-custom': 'val',
232
+ 'x-list': ['a', 'b'],
233
+ });
234
+ });
235
+ });
236
+
237
+
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test';
2
+ import { ContextService, contextStore, CONTEXT_SERVICE_TOKEN } from '../../src/core/context-service';
3
+ import { Context } from '../../src/core/context';
4
+ import { Container } from '../../src/di/container';
5
+
6
+ describe('ContextService', () => {
7
+ let contextService: ContextService;
8
+ let container: Container;
9
+
10
+ beforeEach(() => {
11
+ container = new Container();
12
+ contextService = new ContextService();
13
+ container.registerInstance(CONTEXT_SERVICE_TOKEN, contextService);
14
+ });
15
+
16
+ test('should create ContextService instance', () => {
17
+ expect(contextService).toBeInstanceOf(ContextService);
18
+ });
19
+
20
+ test('should get context from AsyncLocalStorage', async () => {
21
+ const request = new Request('http://localhost:3000/api/users?id=1');
22
+ const context = new Context(request);
23
+
24
+ await contextStore.run(context, async () => {
25
+ const retrievedContext = contextService.getContext();
26
+ expect(retrievedContext).toBe(context);
27
+ expect(retrievedContext?.getQuery('id')).toBe('1');
28
+ });
29
+ });
30
+
31
+ test('should return undefined when not in request context', () => {
32
+ const context = contextService.getContext();
33
+ expect(context).toBeUndefined();
34
+ });
35
+
36
+ test('should get header from context', async () => {
37
+ const request = new Request('http://localhost:3000/api/users', {
38
+ headers: { 'Authorization': 'Bearer token123' },
39
+ });
40
+ const context = new Context(request);
41
+
42
+ await contextStore.run(context, async () => {
43
+ const header = contextService.getHeader('Authorization');
44
+ expect(header).toBe('Bearer token123');
45
+ });
46
+ });
47
+
48
+ test('should return null for non-existent header', async () => {
49
+ const request = new Request('http://localhost:3000/api/users');
50
+ const context = new Context(request);
51
+
52
+ await contextStore.run(context, async () => {
53
+ const header = contextService.getHeader('NonExistent');
54
+ expect(header).toBeNull();
55
+ });
56
+ });
57
+
58
+ test('should get query parameter from context', async () => {
59
+ const request = new Request('http://localhost:3000/api/users?name=John&age=30');
60
+ const context = new Context(request);
61
+
62
+ await contextStore.run(context, async () => {
63
+ expect(contextService.getQuery('name')).toBe('John');
64
+ expect(contextService.getQuery('age')).toBe('30');
65
+ expect(contextService.getQuery('unknown')).toBeNull();
66
+ });
67
+ });
68
+
69
+ test('should get all query parameters', async () => {
70
+ const request = new Request('http://localhost:3000/api/users?name=John&age=30');
71
+ const context = new Context(request);
72
+
73
+ await contextStore.run(context, async () => {
74
+ const all = contextService.getQueryAll();
75
+ expect(all.name).toBe('John');
76
+ expect(all.age).toBe('30');
77
+ });
78
+ });
79
+
80
+ test('should get path parameter from context', async () => {
81
+ const request = new Request('http://localhost:3000/api/users/123');
82
+ const context = new Context(request);
83
+ context.params = { id: '123' };
84
+
85
+ await contextStore.run(context, async () => {
86
+ expect(contextService.getParam('id')).toBe('123');
87
+ expect(contextService.getParam('unknown')).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ test('should return null when body is not parsed', async () => {
92
+ const request = new Request('http://localhost:3000/api/users', {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ name: 'John' }),
96
+ });
97
+ const context = new Context(request);
98
+
99
+ await contextStore.run(context, async () => {
100
+ expect(contextService.getBody()).toBeNull();
101
+ });
102
+ });
103
+
104
+ test('should get request method', async () => {
105
+ const request = new Request('http://localhost:3000/api/users', {
106
+ method: 'POST',
107
+ });
108
+ const context = new Context(request);
109
+
110
+ await contextStore.run(context, async () => {
111
+ expect(contextService.getMethod()).toBe('POST');
112
+ });
113
+ });
114
+
115
+ test('should get request path', async () => {
116
+ const request = new Request('http://localhost:3000/api/users');
117
+ const context = new Context(request);
118
+
119
+ await contextStore.run(context, async () => {
120
+ expect(contextService.getPath()).toBe('/api/users');
121
+ });
122
+ });
123
+
124
+ test('should get request URL', async () => {
125
+ const request = new Request('http://localhost:3000/api/users?id=1');
126
+ const context = new Context(request);
127
+
128
+ await contextStore.run(context, async () => {
129
+ const url = contextService.getUrl();
130
+ expect(url).toBeDefined();
131
+ expect(url?.pathname).toBe('/api/users');
132
+ expect(url?.searchParams.get('id')).toBe('1');
133
+ });
134
+ });
135
+
136
+ test('should get client IP', async () => {
137
+ const request = new Request('http://localhost:3000/api/users', {
138
+ headers: { 'X-Forwarded-For': '192.168.1.1' },
139
+ });
140
+ const context = new Context(request);
141
+
142
+ await contextStore.run(context, async () => {
143
+ const ip = contextService.getClientIp();
144
+ expect(ip).toBe('192.168.1.1');
145
+ });
146
+ });
147
+
148
+ test('should set response header', async () => {
149
+ const request = new Request('http://localhost:3000/api/users');
150
+ const context = new Context(request);
151
+
152
+ await contextStore.run(context, async () => {
153
+ contextService.setHeader('Content-Type', 'application/json');
154
+ expect(context.responseHeaders.get('Content-Type')).toBe('application/json');
155
+ });
156
+ });
157
+
158
+ test('should set status code', async () => {
159
+ const request = new Request('http://localhost:3000/api/users');
160
+ const context = new Context(request);
161
+
162
+ await contextStore.run(context, async () => {
163
+ contextService.setStatus(404);
164
+ expect(context.statusCode).toBe(404);
165
+ });
166
+ });
167
+
168
+ test('should handle multiple concurrent requests', async () => {
169
+ const request1 = new Request('http://localhost:3000/api/users?name=User1');
170
+ const request2 = new Request('http://localhost:3000/api/users?name=User2');
171
+ const context1 = new Context(request1);
172
+ const context2 = new Context(request2);
173
+
174
+ // 模拟并发请求
175
+ const promises = [
176
+ contextStore.run(context1, async () => {
177
+ await new Promise(resolve => setTimeout(resolve, 10));
178
+ return contextService.getQuery('name');
179
+ }),
180
+ contextStore.run(context2, async () => {
181
+ await new Promise(resolve => setTimeout(resolve, 10));
182
+ return contextService.getQuery('name');
183
+ }),
184
+ ];
185
+
186
+ const results = await Promise.all(promises);
187
+ expect(results[0]).toBe('User1');
188
+ expect(results[1]).toBe('User2');
189
+ });
190
+ });
191
+
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test';
2
+ import { Container } from '../../src/di/container';
3
+ import { Injectable } from '../../src/di/decorators';
4
+ import { Lifecycle } from '../../src/di/types';
5
+ import { contextStore } from '../../src/core/context-service';
6
+ import { Context } from '../../src/core/context';
7
+
8
+ describe('Lifecycle.Scoped', () => {
9
+ let container: Container;
10
+
11
+ beforeEach(() => {
12
+ container = new Container();
13
+ });
14
+
15
+ test('should create new instance for each request', async () => {
16
+ @Injectable({ lifecycle: Lifecycle.Scoped })
17
+ class ScopedService {
18
+ public readonly id: string;
19
+
20
+ public constructor() {
21
+ this.id = Math.random().toString(36).substring(7);
22
+ }
23
+ }
24
+
25
+ container.register(ScopedService);
26
+
27
+ const request1 = new Request('http://localhost:3000/api/test1');
28
+ const request2 = new Request('http://localhost:3000/api/test2');
29
+ const context1 = new Context(request1);
30
+ const context2 = new Context(request2);
31
+
32
+ let instance1: ScopedService;
33
+ let instance2: ScopedService;
34
+
35
+ await contextStore.run(context1, async () => {
36
+ instance1 = container.resolve(ScopedService);
37
+ });
38
+
39
+ await contextStore.run(context2, async () => {
40
+ instance2 = container.resolve(ScopedService);
41
+ });
42
+
43
+ // 每个请求应该有独立的实例
44
+ expect(instance1!.id).not.toBe(instance2!.id);
45
+ });
46
+
47
+ test('should reuse instance within same request', async () => {
48
+ @Injectable({ lifecycle: Lifecycle.Scoped })
49
+ class ScopedService {
50
+ public readonly id: string;
51
+
52
+ public constructor() {
53
+ this.id = Math.random().toString(36).substring(7);
54
+ }
55
+ }
56
+
57
+ container.register(ScopedService);
58
+
59
+ const request = new Request('http://localhost:3000/api/test');
60
+ const context = new Context(request);
61
+
62
+ let instance1: ScopedService;
63
+ let instance2: ScopedService;
64
+
65
+ await contextStore.run(context, async () => {
66
+ instance1 = container.resolve(ScopedService);
67
+ instance2 = container.resolve(ScopedService);
68
+ });
69
+
70
+ // 同一请求内应该复用同一个实例
71
+ expect(instance1!.id).toBe(instance2!.id);
72
+ });
73
+
74
+ test('should handle scoped service with dependencies', async () => {
75
+ @Injectable({ lifecycle: Lifecycle.Singleton })
76
+ class SingletonService {
77
+ public readonly id = 'singleton';
78
+ }
79
+
80
+ @Injectable({ lifecycle: Lifecycle.Scoped })
81
+ class ScopedService {
82
+ public constructor(public readonly singleton: SingletonService) {}
83
+ }
84
+
85
+ container.register(SingletonService);
86
+ container.register(ScopedService);
87
+
88
+ const request = new Request('http://localhost:3000/api/test');
89
+ const context = new Context(request);
90
+
91
+ let scopedInstance: ScopedService;
92
+
93
+ await contextStore.run(context, async () => {
94
+ scopedInstance = container.resolve(ScopedService);
95
+ });
96
+
97
+ expect(scopedInstance!.singleton).toBeDefined();
98
+ expect(scopedInstance!.singleton.id).toBe('singleton');
99
+ });
100
+
101
+ test('should handle scoped service depending on another scoped service', async () => {
102
+ @Injectable({ lifecycle: Lifecycle.Scoped })
103
+ class ScopedServiceA {
104
+ public readonly id = 'A';
105
+ }
106
+
107
+ @Injectable({ lifecycle: Lifecycle.Scoped })
108
+ class ScopedServiceB {
109
+ public constructor(public readonly serviceA: ScopedServiceA) {}
110
+ }
111
+
112
+ container.register(ScopedServiceA);
113
+ container.register(ScopedServiceB);
114
+
115
+ const request = new Request('http://localhost:3000/api/test');
116
+ const context = new Context(request);
117
+
118
+ let serviceB: ScopedServiceB;
119
+ let serviceA: ScopedServiceA;
120
+
121
+ await contextStore.run(context, async () => {
122
+ serviceB = container.resolve(ScopedServiceB);
123
+ serviceA = container.resolve(ScopedServiceA);
124
+ });
125
+
126
+ // 同一请求内,ScopedServiceB 应该注入同一个 ScopedServiceA 实例
127
+ expect(serviceB!.serviceA).toBe(serviceA!);
128
+ });
129
+
130
+ test('should return undefined when resolving scoped service outside request context', () => {
131
+ @Injectable({ lifecycle: Lifecycle.Scoped })
132
+ class ScopedService {}
133
+
134
+ container.register(ScopedService);
135
+
136
+ // 不在请求上下文中,应该能够解析(但实例不会被缓存)
137
+ const instance = container.resolve(ScopedService);
138
+ expect(instance).toBeDefined();
139
+ expect(instance).toBeInstanceOf(ScopedService);
140
+ });
141
+
142
+ test('should handle concurrent requests with scoped services', async () => {
143
+ @Injectable({ lifecycle: Lifecycle.Scoped })
144
+ class ScopedService {
145
+ public readonly requestId: string;
146
+
147
+ public constructor() {
148
+ this.requestId = Math.random().toString(36).substring(7);
149
+ }
150
+ }
151
+
152
+ container.register(ScopedService);
153
+
154
+ const request1 = new Request('http://localhost:3000/api/test1');
155
+ const request2 = new Request('http://localhost:3000/api/test2');
156
+ const context1 = new Context(request1);
157
+ const context2 = new Context(request2);
158
+
159
+ const promises = [
160
+ contextStore.run(context1, async () => {
161
+ const instance1 = container.resolve(ScopedService);
162
+ await new Promise(resolve => setTimeout(resolve, 10));
163
+ const instance2 = container.resolve(ScopedService);
164
+ return { instance1: instance1.requestId, instance2: instance2.requestId };
165
+ }),
166
+ contextStore.run(context2, async () => {
167
+ const instance1 = container.resolve(ScopedService);
168
+ await new Promise(resolve => setTimeout(resolve, 10));
169
+ const instance2 = container.resolve(ScopedService);
170
+ return { instance1: instance1.requestId, instance2: instance2.requestId };
171
+ }),
172
+ ];
173
+
174
+ const results = await Promise.all(promises);
175
+
176
+ // 每个请求内的实例应该相同
177
+ expect(results[0].instance1).toBe(results[0].instance2);
178
+ expect(results[1].instance1).toBe(results[1].instance2);
179
+
180
+ // 不同请求的实例应该不同
181
+ expect(results[0].instance1).not.toBe(results[1].instance1);
182
+ });
183
+
184
+ test('should handle scoped service with factory', async () => {
185
+ let factoryCallCount = 0;
186
+
187
+ container.register('ScopedService', {
188
+ lifecycle: Lifecycle.Scoped,
189
+ factory: () => {
190
+ factoryCallCount++;
191
+ return { id: factoryCallCount };
192
+ },
193
+ });
194
+
195
+ const request1 = new Request('http://localhost:3000/api/test1');
196
+ const request2 = new Request('http://localhost:3000/api/test2');
197
+ const context1 = new Context(request1);
198
+ const context2 = new Context(request2);
199
+
200
+ let instance1: { id: number };
201
+ let instance2: { id: number };
202
+ let instance1Again: { id: number };
203
+
204
+ await contextStore.run(context1, async () => {
205
+ instance1 = container.resolve<{ id: number }>('ScopedService');
206
+ instance1Again = container.resolve<{ id: number }>('ScopedService');
207
+ });
208
+
209
+ await contextStore.run(context2, async () => {
210
+ instance2 = container.resolve<{ id: number }>('ScopedService');
211
+ });
212
+
213
+ // 同一请求内应该复用实例
214
+ expect(instance1!.id).toBe(instance1Again!.id);
215
+
216
+ // 不同请求应该创建新实例
217
+ expect(instance1!.id).not.toBe(instance2!.id);
218
+
219
+ // 工厂函数应该被调用 2 次(每个请求一次)
220
+ expect(factoryCallCount).toBe(2);
221
+ });
222
+ });
223
+
@@ -1,20 +1,25 @@
1
- import { describe, expect, test, beforeEach } from 'bun:test';
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
2
2
  import { Application } from '../../src/core/application';
3
3
  import { Context } from '../../src/core/context';
4
4
  import { Controller } from '../../src/controller';
5
5
  import { GET } from '../../src/router/decorators';
6
6
  import { createRateLimitMiddleware, MemoryRateLimitStore, type RateLimitOptions } from '../../src/middleware/builtin/rate-limit';
7
7
  import { RateLimit } from '../../src/middleware/decorators';
8
+ import { getTestPort } from '../utils/test-port';
8
9
 
9
10
  describe('Rate Limit Middleware', () => {
10
11
  let app: Application;
11
12
  let port: number;
12
13
 
13
14
  beforeEach(() => {
14
- port = 3000 + Math.floor(Math.random() * 10000);
15
+ port = getTestPort();
15
16
  app = new Application({ port });
16
17
  });
17
18
 
19
+ afterEach(async () => {
20
+ await app.stop();
21
+ });
22
+
18
23
  test('should allow requests within limit', async () => {
19
24
  const middleware = createRateLimitMiddleware({
20
25
  max: 5,