@onebun/core 0.2.5 → 0.2.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.
- package/package.json +6 -6
- package/src/application/application.test.ts +865 -1
- package/src/application/application.ts +247 -48
- package/src/application/multi-service-application.test.ts +15 -0
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +7 -1
- package/src/decorators/decorators.test.ts +28 -0
- package/src/decorators/decorators.ts +26 -0
- package/src/docs-examples.test.ts +29 -1
- package/src/index.ts +1 -0
- package/src/module/module.test.ts +60 -7
- package/src/module/module.ts +36 -0
- package/src/queue/docs-examples.test.ts +72 -12
- package/src/queue/index.ts +4 -0
- package/src/queue/queue-service-proxy.test.ts +82 -0
- package/src/queue/queue-service-proxy.ts +114 -0
- package/src/queue/types.ts +2 -2
- package/src/types.ts +62 -3
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
1
4
|
import { type as arktype } from 'arktype';
|
|
2
5
|
import {
|
|
3
6
|
describe,
|
|
@@ -10,7 +13,14 @@ import {
|
|
|
10
13
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
11
14
|
import { register } from 'prom-client';
|
|
12
15
|
|
|
13
|
-
import type {
|
|
16
|
+
import type { QueueAdapter, Subscription } from '../queue/types';
|
|
17
|
+
import type { ApplicationOptions, ModuleInstance } from '../types';
|
|
18
|
+
import type {
|
|
19
|
+
MiddlewareClass,
|
|
20
|
+
OneBunRequest,
|
|
21
|
+
OneBunResponse,
|
|
22
|
+
} from '../types';
|
|
23
|
+
import type { OnModuleConfigure } from '../types';
|
|
14
24
|
|
|
15
25
|
import {
|
|
16
26
|
Module,
|
|
@@ -23,10 +33,17 @@ import {
|
|
|
23
33
|
Header,
|
|
24
34
|
Cookie,
|
|
25
35
|
Req,
|
|
36
|
+
UseMiddleware,
|
|
37
|
+
Middleware,
|
|
26
38
|
} from '../decorators/decorators';
|
|
27
39
|
import { Controller as BaseController } from '../module/controller';
|
|
40
|
+
import { BaseMiddleware } from '../module/middleware';
|
|
41
|
+
import { Service } from '../module/service';
|
|
42
|
+
import { QueueService, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from '../queue';
|
|
43
|
+
import { Subscribe } from '../queue/decorators';
|
|
28
44
|
import { makeMockLoggerLayer } from '../testing/test-utils';
|
|
29
45
|
|
|
46
|
+
|
|
30
47
|
import { OneBunApplication } from './application';
|
|
31
48
|
|
|
32
49
|
// Helper function to create app with mock logger to suppress logs in tests
|
|
@@ -2079,6 +2096,179 @@ describe('OneBunApplication', () => {
|
|
|
2079
2096
|
expect(response.status).toBe(404);
|
|
2080
2097
|
});
|
|
2081
2098
|
|
|
2099
|
+
describe('Static file serving', () => {
|
|
2100
|
+
test('should serve file from static root when static.root is set', async () => {
|
|
2101
|
+
const pathMod = await import('node:path');
|
|
2102
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2103
|
+
try {
|
|
2104
|
+
const indexPath = pathMod.join(tmpDir, 'index.html');
|
|
2105
|
+
fs.writeFileSync(indexPath, '<html>Hello</html>', 'utf8');
|
|
2106
|
+
|
|
2107
|
+
@Module({})
|
|
2108
|
+
class TestModule {}
|
|
2109
|
+
|
|
2110
|
+
const app = createTestApp(TestModule, {
|
|
2111
|
+
static: { root: tmpDir },
|
|
2112
|
+
});
|
|
2113
|
+
await app.start();
|
|
2114
|
+
|
|
2115
|
+
const request = new Request('http://localhost:3000/index.html', { method: 'GET' });
|
|
2116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2117
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2118
|
+
|
|
2119
|
+
expect(response.status).toBe(200);
|
|
2120
|
+
expect(await response.text()).toBe('<html>Hello</html>');
|
|
2121
|
+
} finally {
|
|
2122
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
test('should return 404 when static is not configured', async () => {
|
|
2127
|
+
@Module({})
|
|
2128
|
+
class TestModule {}
|
|
2129
|
+
|
|
2130
|
+
const app = createTestApp(TestModule);
|
|
2131
|
+
await app.start();
|
|
2132
|
+
|
|
2133
|
+
const request = new Request('http://localhost:3000/', { method: 'GET' });
|
|
2134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2135
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2136
|
+
|
|
2137
|
+
expect(response.status).toBe(404);
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
test('should respect pathPrefix and serve only under prefix', async () => {
|
|
2141
|
+
const pathMod = await import('node:path');
|
|
2142
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2143
|
+
try {
|
|
2144
|
+
fs.writeFileSync(pathMod.join(tmpDir, 'foo.html'), '<html>Foo</html>', 'utf8');
|
|
2145
|
+
|
|
2146
|
+
@Module({})
|
|
2147
|
+
class TestModule {}
|
|
2148
|
+
|
|
2149
|
+
const app = createTestApp(TestModule, {
|
|
2150
|
+
static: { root: tmpDir, pathPrefix: '/app' },
|
|
2151
|
+
});
|
|
2152
|
+
await app.start();
|
|
2153
|
+
|
|
2154
|
+
const reqUnderPrefix = new Request('http://localhost:3000/app/foo.html', { method: 'GET' });
|
|
2155
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2156
|
+
const resUnder = await (mockServer as any).fetchHandler(reqUnderPrefix);
|
|
2157
|
+
expect(resUnder.status).toBe(200);
|
|
2158
|
+
expect(await resUnder.text()).toBe('<html>Foo</html>');
|
|
2159
|
+
|
|
2160
|
+
const reqOutsidePrefix = new Request('http://localhost:3000/foo.html', { method: 'GET' });
|
|
2161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2162
|
+
const resOutside = await (mockServer as any).fetchHandler(reqOutsidePrefix);
|
|
2163
|
+
expect(resOutside.status).toBe(404);
|
|
2164
|
+
} finally {
|
|
2165
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
test('should return fallbackFile for missing path when fallbackFile is set', async () => {
|
|
2170
|
+
const pathMod = await import('node:path');
|
|
2171
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2172
|
+
try {
|
|
2173
|
+
fs.writeFileSync(pathMod.join(tmpDir, 'index.html'), '<html>SPA</html>', 'utf8');
|
|
2174
|
+
|
|
2175
|
+
@Module({})
|
|
2176
|
+
class TestModule {}
|
|
2177
|
+
|
|
2178
|
+
const app = createTestApp(TestModule, {
|
|
2179
|
+
static: { root: tmpDir, fallbackFile: 'index.html' },
|
|
2180
|
+
});
|
|
2181
|
+
await app.start();
|
|
2182
|
+
|
|
2183
|
+
const request = new Request('http://localhost:3000/any/client/route', { method: 'GET' });
|
|
2184
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2185
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2186
|
+
|
|
2187
|
+
expect(response.status).toBe(200);
|
|
2188
|
+
expect(await response.text()).toBe('<html>SPA</html>');
|
|
2189
|
+
} finally {
|
|
2190
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
test('should reject path traversal and return 404', async () => {
|
|
2195
|
+
const pathMod = await import('node:path');
|
|
2196
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2197
|
+
try {
|
|
2198
|
+
fs.writeFileSync(pathMod.join(tmpDir, 'safe.txt'), 'safe', 'utf8');
|
|
2199
|
+
|
|
2200
|
+
@Module({})
|
|
2201
|
+
class TestModule {}
|
|
2202
|
+
|
|
2203
|
+
const app = createTestApp(TestModule, {
|
|
2204
|
+
static: { root: tmpDir },
|
|
2205
|
+
});
|
|
2206
|
+
await app.start();
|
|
2207
|
+
|
|
2208
|
+
const request = new Request('http://localhost:3000/../etc/passwd', { method: 'GET' });
|
|
2209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2210
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2211
|
+
|
|
2212
|
+
expect(response.status).toBe(404);
|
|
2213
|
+
} finally {
|
|
2214
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
test('should cache file existence and serve from cache on second request', async () => {
|
|
2219
|
+
const pathMod = await import('node:path');
|
|
2220
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2221
|
+
try {
|
|
2222
|
+
const filePath = pathMod.join(tmpDir, 'cached.html');
|
|
2223
|
+
fs.writeFileSync(filePath, '<html>Cached</html>', 'utf8');
|
|
2224
|
+
|
|
2225
|
+
@Module({})
|
|
2226
|
+
class TestModule {}
|
|
2227
|
+
|
|
2228
|
+
const app = createTestApp(TestModule, {
|
|
2229
|
+
static: { root: tmpDir, fileExistenceCacheTtlMs: 60_000 },
|
|
2230
|
+
});
|
|
2231
|
+
await app.start();
|
|
2232
|
+
|
|
2233
|
+
const request = new Request('http://localhost:3000/cached.html', { method: 'GET' });
|
|
2234
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2235
|
+
const response1 = await (mockServer as any).fetchHandler(request);
|
|
2236
|
+
expect(response1.status).toBe(200);
|
|
2237
|
+
expect(await response1.text()).toBe('<html>Cached</html>');
|
|
2238
|
+
|
|
2239
|
+
// Second request should still return the file (cache hit; no extra disk read for existence)
|
|
2240
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2241
|
+
const response2 = await (mockServer as any).fetchHandler(request);
|
|
2242
|
+
expect(response2.status).toBe(200);
|
|
2243
|
+
expect(await response2.text()).toBe('<html>Cached</html>');
|
|
2244
|
+
} finally {
|
|
2245
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test('should not serve static for POST', async () => {
|
|
2250
|
+
const pathMod = await import('node:path');
|
|
2251
|
+
const tmpDir = fs.mkdtempSync(pathMod.join(fs.realpathSync(os.tmpdir()), 'onebun-static-'));
|
|
2252
|
+
try {
|
|
2253
|
+
fs.writeFileSync(pathMod.join(tmpDir, 'index.html'), '<html>Hi</html>', 'utf8');
|
|
2254
|
+
|
|
2255
|
+
@Module({})
|
|
2256
|
+
class TestModule {}
|
|
2257
|
+
|
|
2258
|
+
const app = createTestApp(TestModule, { static: { root: tmpDir } });
|
|
2259
|
+
await app.start();
|
|
2260
|
+
|
|
2261
|
+
const request = new Request('http://localhost:3000/index.html', { method: 'POST' });
|
|
2262
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2263
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2264
|
+
|
|
2265
|
+
expect(response.status).toBe(404);
|
|
2266
|
+
} finally {
|
|
2267
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2082
2272
|
test('should handle method not allowed', async () => {
|
|
2083
2273
|
@Controller('/api')
|
|
2084
2274
|
class ApiController extends BaseController {
|
|
@@ -2562,6 +2752,517 @@ describe('OneBunApplication', () => {
|
|
|
2562
2752
|
// Verify they are the same (no duplication due to trailing slash)
|
|
2563
2753
|
expect(recordedMetrics[0].route).toBe(recordedMetrics[1].route);
|
|
2564
2754
|
});
|
|
2755
|
+
|
|
2756
|
+
describe('Middleware composition (docs: controllers.md#middleware-execution-order)', () => {
|
|
2757
|
+
test('should run route-level middlewares left-to-right then handler', async () => {
|
|
2758
|
+
const order: string[] = [];
|
|
2759
|
+
|
|
2760
|
+
class MwA extends BaseMiddleware {
|
|
2761
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2762
|
+
order.push('MwA');
|
|
2763
|
+
const res = await next();
|
|
2764
|
+
order.push('MwA-after');
|
|
2765
|
+
|
|
2766
|
+
return res;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
class MwB extends BaseMiddleware {
|
|
2771
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2772
|
+
order.push('MwB');
|
|
2773
|
+
const res = await next();
|
|
2774
|
+
order.push('MwB-after');
|
|
2775
|
+
|
|
2776
|
+
return res;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
@Controller('/api')
|
|
2781
|
+
class ApiController extends BaseController {
|
|
2782
|
+
@Get('/chain')
|
|
2783
|
+
@UseMiddleware(MwA, MwB)
|
|
2784
|
+
getChain() {
|
|
2785
|
+
order.push('handler');
|
|
2786
|
+
|
|
2787
|
+
return this.success({ ok: true });
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
@Module({ controllers: [ApiController] })
|
|
2792
|
+
class TestModule {}
|
|
2793
|
+
|
|
2794
|
+
const app = createTestApp(TestModule);
|
|
2795
|
+
await app.start();
|
|
2796
|
+
|
|
2797
|
+
const request = new Request('http://localhost:3000/api/chain', { method: 'GET' });
|
|
2798
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2799
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2800
|
+
const body = await response.json();
|
|
2801
|
+
|
|
2802
|
+
expect(response.status).toBe(200);
|
|
2803
|
+
expect(body.result).toEqual({ ok: true });
|
|
2804
|
+
expect(order).toEqual(['MwA', 'MwB', 'handler', 'MwB-after', 'MwA-after']);
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
test('should run controller-level then route-level middleware', async () => {
|
|
2808
|
+
const order: string[] = [];
|
|
2809
|
+
|
|
2810
|
+
class CtrlMw extends BaseMiddleware {
|
|
2811
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2812
|
+
order.push('CtrlMw');
|
|
2813
|
+
|
|
2814
|
+
return await next();
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
class RouteMw extends BaseMiddleware {
|
|
2819
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2820
|
+
order.push('RouteMw');
|
|
2821
|
+
|
|
2822
|
+
return await next();
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
@Controller('/api')
|
|
2827
|
+
@UseMiddleware(CtrlMw)
|
|
2828
|
+
class ApiController extends BaseController {
|
|
2829
|
+
@Get('/dashboard')
|
|
2830
|
+
getDashboard() {
|
|
2831
|
+
order.push('handler');
|
|
2832
|
+
|
|
2833
|
+
return this.success({ stats: {} });
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
@Get('/settings')
|
|
2837
|
+
@UseMiddleware(RouteMw)
|
|
2838
|
+
getSettings() {
|
|
2839
|
+
order.push('handler-settings');
|
|
2840
|
+
|
|
2841
|
+
return this.success({ updated: false });
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
@Module({ controllers: [ApiController] })
|
|
2846
|
+
class TestModule {}
|
|
2847
|
+
|
|
2848
|
+
const app = createTestApp(TestModule);
|
|
2849
|
+
await app.start();
|
|
2850
|
+
|
|
2851
|
+
order.length = 0;
|
|
2852
|
+
const req1 = new Request('http://localhost:3000/api/dashboard', { method: 'GET' });
|
|
2853
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2854
|
+
const res1 = await (mockServer as any).fetchHandler(req1);
|
|
2855
|
+
expect(res1.status).toBe(200);
|
|
2856
|
+
expect(order).toEqual(['CtrlMw', 'handler']);
|
|
2857
|
+
|
|
2858
|
+
order.length = 0;
|
|
2859
|
+
const req2 = new Request('http://localhost:3000/api/settings', { method: 'GET' });
|
|
2860
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2861
|
+
const res2 = await (mockServer as any).fetchHandler(req2);
|
|
2862
|
+
expect(res2.status).toBe(200);
|
|
2863
|
+
expect(order).toEqual(['CtrlMw', 'RouteMw', 'handler-settings']);
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
test('should run module-level then controller-level then route-level middleware', async () => {
|
|
2867
|
+
const order: string[] = [];
|
|
2868
|
+
|
|
2869
|
+
class ModuleMw extends BaseMiddleware {
|
|
2870
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2871
|
+
order.push('ModuleMw');
|
|
2872
|
+
|
|
2873
|
+
return await next();
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
class CtrlMw extends BaseMiddleware {
|
|
2878
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2879
|
+
order.push('CtrlMw');
|
|
2880
|
+
|
|
2881
|
+
return await next();
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
class RouteMw extends BaseMiddleware {
|
|
2886
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2887
|
+
order.push('RouteMw');
|
|
2888
|
+
|
|
2889
|
+
return await next();
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
@Controller('/admin')
|
|
2894
|
+
@UseMiddleware(CtrlMw)
|
|
2895
|
+
class AdminController extends BaseController {
|
|
2896
|
+
@Get('/users')
|
|
2897
|
+
@UseMiddleware(RouteMw)
|
|
2898
|
+
getUsers() {
|
|
2899
|
+
order.push('handler');
|
|
2900
|
+
|
|
2901
|
+
return this.success({ users: [] });
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
@Module({ controllers: [AdminController] })
|
|
2906
|
+
class TestModule implements OnModuleConfigure {
|
|
2907
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
2908
|
+
return [ModuleMw];
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
const app = createTestApp(TestModule);
|
|
2913
|
+
await app.start();
|
|
2914
|
+
|
|
2915
|
+
const request = new Request('http://localhost:3000/admin/users', { method: 'GET' });
|
|
2916
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2917
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2918
|
+
const body = await response.json();
|
|
2919
|
+
|
|
2920
|
+
expect(response.status).toBe(200);
|
|
2921
|
+
expect(body.result).toEqual({ users: [] });
|
|
2922
|
+
expect(order).toEqual(['ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
test('should run application-wide then module then controller then route middleware', async () => {
|
|
2926
|
+
const order: string[] = [];
|
|
2927
|
+
|
|
2928
|
+
class GlobalMw extends BaseMiddleware {
|
|
2929
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2930
|
+
order.push('GlobalMw');
|
|
2931
|
+
|
|
2932
|
+
return await next();
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
class ModuleMw extends BaseMiddleware {
|
|
2937
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2938
|
+
order.push('ModuleMw');
|
|
2939
|
+
|
|
2940
|
+
return await next();
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
class CtrlMw extends BaseMiddleware {
|
|
2945
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2946
|
+
order.push('CtrlMw');
|
|
2947
|
+
|
|
2948
|
+
return await next();
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
class RouteMw extends BaseMiddleware {
|
|
2953
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2954
|
+
order.push('RouteMw');
|
|
2955
|
+
|
|
2956
|
+
return await next();
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
@Controller('/admin')
|
|
2961
|
+
@UseMiddleware(CtrlMw)
|
|
2962
|
+
class AdminController extends BaseController {
|
|
2963
|
+
@Get('/stats')
|
|
2964
|
+
@UseMiddleware(RouteMw)
|
|
2965
|
+
getStats() {
|
|
2966
|
+
order.push('handler');
|
|
2967
|
+
|
|
2968
|
+
return this.success({ stats: {} });
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
@Module({ controllers: [AdminController] })
|
|
2973
|
+
class TestModule implements OnModuleConfigure {
|
|
2974
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
2975
|
+
return [ModuleMw];
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
const app = createTestApp(TestModule, { middleware: [GlobalMw] });
|
|
2980
|
+
await app.start();
|
|
2981
|
+
|
|
2982
|
+
const request = new Request('http://localhost:3000/admin/stats', { method: 'GET' });
|
|
2983
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2984
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2985
|
+
const body = await response.json();
|
|
2986
|
+
|
|
2987
|
+
expect(response.status).toBe(200);
|
|
2988
|
+
expect(body.result).toEqual({ stats: {} });
|
|
2989
|
+
expect(order).toEqual(['GlobalMw', 'ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
test('should short-circuit when middleware returns without calling next()', async () => {
|
|
2993
|
+
const order: string[] = [];
|
|
2994
|
+
|
|
2995
|
+
class AuthMw extends BaseMiddleware {
|
|
2996
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2997
|
+
order.push('AuthMw');
|
|
2998
|
+
const token = req.headers.get('X-Test-Token');
|
|
2999
|
+
if (token !== 'secret') {
|
|
3000
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
3001
|
+
status: 403,
|
|
3002
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
3003
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
return await next();
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
@Controller('/api')
|
|
3012
|
+
class ApiController extends BaseController {
|
|
3013
|
+
@Get('/protected')
|
|
3014
|
+
@UseMiddleware(AuthMw)
|
|
3015
|
+
getProtected() {
|
|
3016
|
+
order.push('handler');
|
|
3017
|
+
|
|
3018
|
+
return { data: 'secret' };
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
@Module({ controllers: [ApiController] })
|
|
3023
|
+
class TestModule {}
|
|
3024
|
+
|
|
3025
|
+
const app = createTestApp(TestModule);
|
|
3026
|
+
await app.start();
|
|
3027
|
+
|
|
3028
|
+
const request = new Request('http://localhost:3000/api/protected', {
|
|
3029
|
+
method: 'GET',
|
|
3030
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
3031
|
+
headers: { 'X-Test-Token': 'wrong' },
|
|
3032
|
+
});
|
|
3033
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3034
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3035
|
+
const body = await response.json();
|
|
3036
|
+
|
|
3037
|
+
expect(response.status).toBe(403);
|
|
3038
|
+
expect(body.error).toBe('Unauthorized');
|
|
3039
|
+
expect(order).toEqual(['AuthMw']);
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
test('should allow middleware to modify response after next() (onion model)', async () => {
|
|
3043
|
+
class AddHeaderAfterNextMw extends BaseMiddleware {
|
|
3044
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3045
|
+
const res = await next();
|
|
3046
|
+
res.headers.set('X-After-Next', 'true');
|
|
3047
|
+
|
|
3048
|
+
return res;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
@Controller('/api')
|
|
3053
|
+
class ApiController extends BaseController {
|
|
3054
|
+
@Get('/onion')
|
|
3055
|
+
@UseMiddleware(AddHeaderAfterNextMw)
|
|
3056
|
+
getOnion() {
|
|
3057
|
+
return this.success({ value: 1 });
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
@Module({ controllers: [ApiController] })
|
|
3062
|
+
class TestModule {}
|
|
3063
|
+
|
|
3064
|
+
const app = createTestApp(TestModule);
|
|
3065
|
+
await app.start();
|
|
3066
|
+
|
|
3067
|
+
const request = new Request('http://localhost:3000/api/onion', { method: 'GET' });
|
|
3068
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3069
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3070
|
+
const body = await response.json();
|
|
3071
|
+
|
|
3072
|
+
expect(response.status).toBe(200);
|
|
3073
|
+
expect(body.result).toEqual({ value: 1 });
|
|
3074
|
+
expect(response.headers.get('X-After-Next')).toBe('true');
|
|
3075
|
+
});
|
|
3076
|
+
});
|
|
3077
|
+
|
|
3078
|
+
describe('Middleware DI (service injection)', () => {
|
|
3079
|
+
test('should inject service into application-wide middleware', async () => {
|
|
3080
|
+
@Service()
|
|
3081
|
+
class RootService {
|
|
3082
|
+
getValue(): string {
|
|
3083
|
+
return 'global';
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
@Middleware()
|
|
3088
|
+
class GlobalMw extends BaseMiddleware {
|
|
3089
|
+
constructor(private readonly svc: RootService) {
|
|
3090
|
+
super();
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3094
|
+
const res = await next();
|
|
3095
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3096
|
+
|
|
3097
|
+
return res;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
@Controller('/api')
|
|
3102
|
+
class ApiController extends BaseController {
|
|
3103
|
+
@Get('/ping')
|
|
3104
|
+
ping() {
|
|
3105
|
+
return this.success({ pong: true });
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
@Module({ controllers: [ApiController], providers: [RootService] })
|
|
3110
|
+
class TestModule {}
|
|
3111
|
+
|
|
3112
|
+
const app = createTestApp(TestModule, { middleware: [GlobalMw] });
|
|
3113
|
+
await app.start();
|
|
3114
|
+
|
|
3115
|
+
const request = new Request('http://localhost:3000/api/ping', { method: 'GET' });
|
|
3116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3117
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3118
|
+
expect(response.status).toBe(200);
|
|
3119
|
+
expect(response.headers.get('X-Injected-Value')).toBe('global');
|
|
3120
|
+
});
|
|
3121
|
+
|
|
3122
|
+
test('should inject service into module-level middleware', async () => {
|
|
3123
|
+
@Service()
|
|
3124
|
+
class FeatureService {
|
|
3125
|
+
getValue(): string {
|
|
3126
|
+
return 'module';
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
@Middleware()
|
|
3131
|
+
class FeatureModuleMw extends BaseMiddleware {
|
|
3132
|
+
constructor(private readonly svc: FeatureService) {
|
|
3133
|
+
super();
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3137
|
+
const res = await next();
|
|
3138
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3139
|
+
|
|
3140
|
+
return res;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
@Controller('/feature')
|
|
3145
|
+
class FeatureController extends BaseController {
|
|
3146
|
+
@Get('/data')
|
|
3147
|
+
getData() {
|
|
3148
|
+
return this.success({ data: true });
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
3153
|
+
class FeatureModule implements OnModuleConfigure {
|
|
3154
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
3155
|
+
return [FeatureModuleMw];
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
3160
|
+
class RootModule {}
|
|
3161
|
+
|
|
3162
|
+
const app = createTestApp(RootModule);
|
|
3163
|
+
await app.start();
|
|
3164
|
+
|
|
3165
|
+
const request = new Request('http://localhost:3000/feature/data', { method: 'GET' });
|
|
3166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3167
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3168
|
+
expect(response.status).toBe(200);
|
|
3169
|
+
expect(response.headers.get('X-Injected-Value')).toBe('module');
|
|
3170
|
+
});
|
|
3171
|
+
|
|
3172
|
+
test('should inject service into controller-level middleware from owner module', async () => {
|
|
3173
|
+
@Service()
|
|
3174
|
+
class FeatureService {
|
|
3175
|
+
getValue(): string {
|
|
3176
|
+
return 'controller';
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
@Middleware()
|
|
3181
|
+
class FeatureCtrlMw extends BaseMiddleware {
|
|
3182
|
+
constructor(private readonly svc: FeatureService) {
|
|
3183
|
+
super();
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3187
|
+
const res = await next();
|
|
3188
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3189
|
+
|
|
3190
|
+
return res;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
@Controller('/feature')
|
|
3195
|
+
@UseMiddleware(FeatureCtrlMw)
|
|
3196
|
+
class FeatureController extends BaseController {
|
|
3197
|
+
@Get('/ctrl')
|
|
3198
|
+
getCtrl() {
|
|
3199
|
+
return this.success({ ok: true });
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
3204
|
+
class FeatureModule {}
|
|
3205
|
+
|
|
3206
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
3207
|
+
class RootModule {}
|
|
3208
|
+
|
|
3209
|
+
const app = createTestApp(RootModule);
|
|
3210
|
+
await app.start();
|
|
3211
|
+
|
|
3212
|
+
const request = new Request('http://localhost:3000/feature/ctrl', { method: 'GET' });
|
|
3213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3214
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3215
|
+
expect(response.status).toBe(200);
|
|
3216
|
+
expect(response.headers.get('X-Injected-Value')).toBe('controller');
|
|
3217
|
+
});
|
|
3218
|
+
|
|
3219
|
+
test('should inject service into route-level middleware from owner module', async () => {
|
|
3220
|
+
@Service()
|
|
3221
|
+
class FeatureService {
|
|
3222
|
+
getValue(): string {
|
|
3223
|
+
return 'route';
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
@Middleware()
|
|
3228
|
+
class FeatureRouteMw extends BaseMiddleware {
|
|
3229
|
+
constructor(private readonly svc: FeatureService) {
|
|
3230
|
+
super();
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3234
|
+
const res = await next();
|
|
3235
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3236
|
+
|
|
3237
|
+
return res;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
@Controller('/feature')
|
|
3242
|
+
class FeatureController extends BaseController {
|
|
3243
|
+
@Get('/route')
|
|
3244
|
+
@UseMiddleware(FeatureRouteMw)
|
|
3245
|
+
getRoute() {
|
|
3246
|
+
return this.success({ ok: true });
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
3251
|
+
class FeatureModule {}
|
|
3252
|
+
|
|
3253
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
3254
|
+
class RootModule {}
|
|
3255
|
+
|
|
3256
|
+
const app = createTestApp(RootModule);
|
|
3257
|
+
await app.start();
|
|
3258
|
+
|
|
3259
|
+
const request = new Request('http://localhost:3000/feature/route', { method: 'GET' });
|
|
3260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3261
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3262
|
+
expect(response.status).toBe(200);
|
|
3263
|
+
expect(response.headers.get('X-Injected-Value')).toBe('route');
|
|
3264
|
+
});
|
|
3265
|
+
});
|
|
2565
3266
|
});
|
|
2566
3267
|
|
|
2567
3268
|
describe('Tracing integration', () => {
|
|
@@ -3173,4 +3874,167 @@ describe('OneBunApplication', () => {
|
|
|
3173
3874
|
await app.stop();
|
|
3174
3875
|
});
|
|
3175
3876
|
});
|
|
3877
|
+
|
|
3878
|
+
describe('queue custom adapter', () => {
|
|
3879
|
+
/** Mock adapter that records constructor options for testing */
|
|
3880
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
3881
|
+
class MockCustomAdapter implements QueueAdapter {
|
|
3882
|
+
static receivedOptions: unknown = null;
|
|
3883
|
+
readonly name = 'mock-custom';
|
|
3884
|
+
readonly type = 'jetstream';
|
|
3885
|
+
private connected = false;
|
|
3886
|
+
|
|
3887
|
+
constructor(options?: unknown) {
|
|
3888
|
+
(this.constructor as typeof MockCustomAdapter).receivedOptions = options;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
async connect(): Promise<void> {
|
|
3892
|
+
this.connected = true;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
async disconnect(): Promise<void> {
|
|
3896
|
+
this.connected = false;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
isConnected(): boolean {
|
|
3900
|
+
return this.connected;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
async publish(): Promise<string> {
|
|
3904
|
+
return 'mock-id';
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
async publishBatch(): Promise<string[]> {
|
|
3908
|
+
return [];
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
async subscribe(): Promise<Subscription> {
|
|
3912
|
+
return {
|
|
3913
|
+
async unsubscribe() {},
|
|
3914
|
+
pause() {},
|
|
3915
|
+
resume() {},
|
|
3916
|
+
pattern: '',
|
|
3917
|
+
isActive: true,
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
async addScheduledJob(): Promise<void> {}
|
|
3922
|
+
async removeScheduledJob(): Promise<boolean> {
|
|
3923
|
+
return false;
|
|
3924
|
+
}
|
|
3925
|
+
async getScheduledJobs(): Promise<import('../queue/types').ScheduledJobInfo[]> {
|
|
3926
|
+
return [];
|
|
3927
|
+
}
|
|
3928
|
+
supports(): boolean {
|
|
3929
|
+
return false;
|
|
3930
|
+
}
|
|
3931
|
+
on(): void {}
|
|
3932
|
+
off(): void {}
|
|
3933
|
+
}
|
|
3934
|
+
/* eslint-enable @typescript-eslint/no-empty-function */
|
|
3935
|
+
|
|
3936
|
+
@Controller('/q')
|
|
3937
|
+
class QueueTestController extends BaseController {
|
|
3938
|
+
@Subscribe('test.event')
|
|
3939
|
+
async handle(): Promise<void> {}
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
@Module({
|
|
3943
|
+
controllers: [QueueTestController],
|
|
3944
|
+
providers: [],
|
|
3945
|
+
})
|
|
3946
|
+
class QueueTestModule {}
|
|
3947
|
+
|
|
3948
|
+
beforeEach(() => {
|
|
3949
|
+
MockCustomAdapter.receivedOptions = null;
|
|
3950
|
+
});
|
|
3951
|
+
|
|
3952
|
+
test('should initialize queue with custom adapter constructor and options', async () => {
|
|
3953
|
+
const app = createTestApp(QueueTestModule, {
|
|
3954
|
+
port: 0,
|
|
3955
|
+
queue: {
|
|
3956
|
+
enabled: true,
|
|
3957
|
+
adapter: MockCustomAdapter,
|
|
3958
|
+
options: { servers: 'nats://localhost:4222' },
|
|
3959
|
+
},
|
|
3960
|
+
});
|
|
3961
|
+
await app.start();
|
|
3962
|
+
|
|
3963
|
+
const queueService = app.getQueueService();
|
|
3964
|
+
expect(queueService).not.toBeNull();
|
|
3965
|
+
expect(MockCustomAdapter.receivedOptions).toEqual({
|
|
3966
|
+
servers: 'nats://localhost:4222',
|
|
3967
|
+
});
|
|
3968
|
+
|
|
3969
|
+
await app.stop();
|
|
3970
|
+
});
|
|
3971
|
+
});
|
|
3972
|
+
|
|
3973
|
+
describe('QueueService DI injection', () => {
|
|
3974
|
+
@Controller('/queue-di')
|
|
3975
|
+
class QueueDiTestController extends BaseController {
|
|
3976
|
+
constructor(private queueService: QueueService) {
|
|
3977
|
+
super();
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
/** Exposed for tests: call publish on injected QueueService */
|
|
3981
|
+
publishTest(pattern: string, data: unknown) {
|
|
3982
|
+
return this.queueService.publish(pattern, data);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
@Controller('/q')
|
|
3987
|
+
class QueueSubscribeController extends BaseController {
|
|
3988
|
+
@Subscribe('di.test')
|
|
3989
|
+
async handle(): Promise<void> {}
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
@Module({
|
|
3993
|
+
controllers: [QueueDiTestController],
|
|
3994
|
+
providers: [],
|
|
3995
|
+
})
|
|
3996
|
+
class QueueDisabledModule {}
|
|
3997
|
+
|
|
3998
|
+
@Module({
|
|
3999
|
+
controllers: [QueueDiTestController, QueueSubscribeController],
|
|
4000
|
+
providers: [],
|
|
4001
|
+
})
|
|
4002
|
+
class QueueEnabledModule {}
|
|
4003
|
+
|
|
4004
|
+
test('when queue is enabled, controller with injected QueueService gets working instance', async () => {
|
|
4005
|
+
const app = createTestApp(QueueEnabledModule, {
|
|
4006
|
+
port: 0,
|
|
4007
|
+
queue: {
|
|
4008
|
+
enabled: true,
|
|
4009
|
+
adapter: 'memory',
|
|
4010
|
+
},
|
|
4011
|
+
});
|
|
4012
|
+
await app.start();
|
|
4013
|
+
|
|
4014
|
+
const rootModule = (app as unknown as { rootModule: ModuleInstance }).rootModule;
|
|
4015
|
+
const controller = rootModule.getControllerInstance?.(QueueDiTestController) as
|
|
4016
|
+
| QueueDiTestController
|
|
4017
|
+
| undefined;
|
|
4018
|
+
expect(controller).toBeDefined();
|
|
4019
|
+
await expect(controller!.publishTest('di.test', {})).resolves.toBeDefined();
|
|
4020
|
+
|
|
4021
|
+
await app.stop();
|
|
4022
|
+
});
|
|
4023
|
+
|
|
4024
|
+
test('when queue is disabled, injected QueueService throws on first method call', async () => {
|
|
4025
|
+
const app = createTestApp(QueueDisabledModule, { port: 0 });
|
|
4026
|
+
await app.start();
|
|
4027
|
+
|
|
4028
|
+
const rootModule = (app as unknown as { rootModule: ModuleInstance }).rootModule;
|
|
4029
|
+
const controller = rootModule.getControllerInstance?.(QueueDiTestController) as
|
|
4030
|
+
| QueueDiTestController
|
|
4031
|
+
| undefined;
|
|
4032
|
+
expect(controller).toBeDefined();
|
|
4033
|
+
await expect(controller!.publishTest('e', {})).rejects.toThrow(
|
|
4034
|
+
QUEUE_NOT_ENABLED_ERROR_MESSAGE,
|
|
4035
|
+
);
|
|
4036
|
+
|
|
4037
|
+
await app.stop();
|
|
4038
|
+
});
|
|
4039
|
+
});
|
|
3176
4040
|
});
|