@onebun/core 0.2.6 → 0.2.8

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/application/application.test.ts +350 -7
  3. package/src/application/application.ts +537 -254
  4. package/src/application/multi-service-application.test.ts +15 -0
  5. package/src/application/multi-service-application.ts +2 -0
  6. package/src/application/multi-service.types.ts +7 -1
  7. package/src/decorators/decorators.ts +213 -0
  8. package/src/docs-examples.test.ts +386 -3
  9. package/src/exception-filters/exception-filters.test.ts +172 -0
  10. package/src/exception-filters/exception-filters.ts +129 -0
  11. package/src/exception-filters/http-exception.ts +22 -0
  12. package/src/exception-filters/index.ts +2 -0
  13. package/src/file/onebun-file.ts +8 -2
  14. package/src/http-guards/http-guards.test.ts +230 -0
  15. package/src/http-guards/http-guards.ts +173 -0
  16. package/src/http-guards/index.ts +1 -0
  17. package/src/index.ts +10 -0
  18. package/src/module/module.test.ts +78 -0
  19. package/src/module/module.ts +55 -7
  20. package/src/queue/docs-examples.test.ts +72 -12
  21. package/src/queue/index.ts +4 -0
  22. package/src/queue/queue-service-proxy.test.ts +82 -0
  23. package/src/queue/queue-service-proxy.ts +114 -0
  24. package/src/queue/types.ts +2 -2
  25. package/src/security/cors-middleware.ts +212 -0
  26. package/src/security/index.ts +19 -0
  27. package/src/security/rate-limit-middleware.ts +276 -0
  28. package/src/security/security-headers-middleware.ts +188 -0
  29. package/src/security/security.test.ts +285 -0
  30. package/src/testing/index.ts +1 -0
  31. package/src/testing/testing-module.test.ts +199 -0
  32. package/src/testing/testing-module.ts +252 -0
  33. package/src/types.ts +153 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -41,11 +41,11 @@
41
41
  "dependencies": {
42
42
  "effect": "^3.13.10",
43
43
  "arktype": "^2.0.0",
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"
44
+ "@onebun/logger": "^0.2.1",
45
+ "@onebun/envs": "^0.2.1",
46
+ "@onebun/metrics": "^0.2.1",
47
+ "@onebun/requests": "^0.2.1",
48
+ "@onebun/trace": "^0.2.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "bun-types": "^1.3.8",
@@ -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,8 @@ import {
10
13
  // eslint-disable-next-line import/no-extraneous-dependencies
11
14
  import { register } from 'prom-client';
12
15
 
13
- import type { ApplicationOptions } from '../types';
16
+ import type { QueueAdapter, Subscription } from '../queue/types';
17
+ import type { ApplicationOptions, ModuleInstance } from '../types';
14
18
  import type {
15
19
  MiddlewareClass,
16
20
  OneBunRequest,
@@ -35,8 +39,11 @@ import {
35
39
  import { Controller as BaseController } from '../module/controller';
36
40
  import { BaseMiddleware } from '../module/middleware';
37
41
  import { Service } from '../module/service';
42
+ import { QueueService, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from '../queue';
43
+ import { Subscribe } from '../queue/decorators';
38
44
  import { makeMockLoggerLayer } from '../testing/test-utils';
39
45
 
46
+
40
47
  import { OneBunApplication } from './application';
41
48
 
42
49
  // Helper function to create app with mock logger to suppress logs in tests
@@ -1835,7 +1842,7 @@ describe('OneBunApplication', () => {
1835
1842
  expect(body.result.hasOptional).toBe(false);
1836
1843
  });
1837
1844
 
1838
- test('should return 500 when required query parameter is missing', async () => {
1845
+ test('should return 400 when required query parameter is missing', async () => {
1839
1846
  @Controller('/api')
1840
1847
  class ApiController extends BaseController {
1841
1848
  @Get('/required-query')
@@ -1862,7 +1869,7 @@ describe('OneBunApplication', () => {
1862
1869
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1863
1870
  const response = await (mockServer as any).fetchHandler(request);
1864
1871
 
1865
- expect(response.status).toBe(500);
1872
+ expect(response.status).toBe(400);
1866
1873
  });
1867
1874
 
1868
1875
  test('should pass validation with required query parameter present', async () => {
@@ -1959,7 +1966,7 @@ describe('OneBunApplication', () => {
1959
1966
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1960
1967
  const response = await (mockServer as any).fetchHandler(request);
1961
1968
 
1962
- expect(response.status).toBe(500);
1969
+ expect(response.status).toBe(400);
1963
1970
  });
1964
1971
 
1965
1972
  test('should validate body with arktype schema', async () => {
@@ -2038,7 +2045,7 @@ describe('OneBunApplication', () => {
2038
2045
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2039
2046
  const response = await (mockServer as any).fetchHandler(request);
2040
2047
 
2041
- expect(response.status).toBe(500);
2048
+ expect(response.status).toBe(400);
2042
2049
  });
2043
2050
 
2044
2051
  test('should handle metrics endpoint', async () => {
@@ -2089,6 +2096,179 @@ describe('OneBunApplication', () => {
2089
2096
  expect(response.status).toBe(404);
2090
2097
  });
2091
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
+
2092
2272
  test('should handle method not allowed', async () => {
2093
2273
  @Controller('/api')
2094
2274
  class ApiController extends BaseController {
@@ -3269,7 +3449,7 @@ describe('OneBunApplication', () => {
3269
3449
  expect(body.result.isUndefined).toBe(true);
3270
3450
  });
3271
3451
 
3272
- test('should return 500 when required cookie is missing', async () => {
3452
+ test('should return 400 when required cookie is missing', async () => {
3273
3453
  @Controller('/api')
3274
3454
  class ApiController extends BaseController {
3275
3455
  @Get('/auth')
@@ -3294,7 +3474,7 @@ describe('OneBunApplication', () => {
3294
3474
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3295
3475
  const response = await (mockServer as any).fetchHandler(request);
3296
3476
 
3297
- expect(response.status).toBe(500);
3477
+ expect(response.status).toBe(400);
3298
3478
  });
3299
3479
 
3300
3480
  test('should extract multiple cookies with @Cookie decorator', async () => {
@@ -3694,4 +3874,167 @@ describe('OneBunApplication', () => {
3694
3874
  await app.stop();
3695
3875
  });
3696
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
+ });
3697
4040
  });