@onebun/core 0.2.6 → 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 +344 -1
- package/src/application/application.ts +239 -44
- 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/docs-examples.test.ts +29 -1
- package/src/index.ts +1 -0
- package/src/module/module.ts +18 -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 +55 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onebun/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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.
|
|
45
|
-
"@onebun/envs": "^0.2.
|
|
46
|
-
"@onebun/metrics": "^0.2.
|
|
47
|
-
"@onebun/requests": "^0.2.
|
|
48
|
-
"@onebun/trace": "^0.2.
|
|
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 {
|
|
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
|
|
@@ -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 {
|
|
@@ -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
|
});
|