@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
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type Context,
|
|
5
|
+
Effect,
|
|
6
|
+
type Layer,
|
|
7
|
+
} from 'effect';
|
|
2
8
|
|
|
3
9
|
import type { Controller } from '../module/controller';
|
|
4
10
|
import type { WsClientData } from '../websocket/ws.types';
|
|
@@ -45,7 +51,13 @@ import {
|
|
|
45
51
|
DEFAULT_SSE_TIMEOUT,
|
|
46
52
|
} from '../module/controller';
|
|
47
53
|
import { OneBunModule } from '../module/module';
|
|
48
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
QueueService,
|
|
56
|
+
QueueServiceProxy,
|
|
57
|
+
QueueServiceTag,
|
|
58
|
+
type QueueAdapter,
|
|
59
|
+
type QueueConfig,
|
|
60
|
+
} from '../queue';
|
|
49
61
|
import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
|
|
50
62
|
import { RedisQueueAdapter } from '../queue/adapters/redis.adapter';
|
|
51
63
|
import { hasQueueDecorators } from '../queue/decorators';
|
|
@@ -88,6 +100,17 @@ try {
|
|
|
88
100
|
// Docs module not available - this is optional
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
// Optional CacheService for static file existence caching
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
105
|
+
let cacheServiceClass: new (...args: unknown[]) => any = null as any;
|
|
106
|
+
try {
|
|
107
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
108
|
+
const cacheModule = require('@onebun/cache');
|
|
109
|
+
cacheServiceClass = cacheModule.CacheService;
|
|
110
|
+
} catch {
|
|
111
|
+
// @onebun/cache not available - use in-memory Map fallback
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
// Import tracing modules directly
|
|
92
115
|
|
|
93
116
|
// Global trace context for current request
|
|
@@ -113,12 +136,12 @@ function clearGlobalTraceContext(): void {
|
|
|
113
136
|
* normalizePath('/') // => '/'
|
|
114
137
|
* normalizePath('/api/v1/') // => '/api/v1'
|
|
115
138
|
*/
|
|
116
|
-
function normalizePath(
|
|
117
|
-
if (
|
|
118
|
-
return
|
|
139
|
+
function normalizePath(pathStr: string): string {
|
|
140
|
+
if (pathStr === '/' || pathStr.length <= 1) {
|
|
141
|
+
return pathStr;
|
|
119
142
|
}
|
|
120
143
|
|
|
121
|
-
return
|
|
144
|
+
return pathStr.endsWith('/') ? pathStr.slice(0, -1) : pathStr;
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
/**
|
|
@@ -191,6 +214,29 @@ function resolveHost(explicitHost: string | undefined): string {
|
|
|
191
214
|
return '0.0.0.0';
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
/** Default TTL for static file existence cache (ms) when not specified */
|
|
218
|
+
const DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS = 60_000;
|
|
219
|
+
|
|
220
|
+
/** Cache key prefix for static file existence in CacheService */
|
|
221
|
+
const STATIC_EXISTS_CACHE_PREFIX = 'onebun:static:exists:';
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve a relative path under a root directory and ensure the result stays inside the root (path traversal protection).
|
|
225
|
+
* @param rootDir - Absolute path to the static root directory
|
|
226
|
+
* @param relativePath - URL path segment (e.g. from request path after prefix); must not contain '..' that escapes root
|
|
227
|
+
* @returns Absolute path if under root, otherwise null
|
|
228
|
+
*/
|
|
229
|
+
function resolvePathUnderRoot(rootDir: string, relativePath: string): string | null {
|
|
230
|
+
const normalized = path.join(rootDir, relativePath.startsWith('/') ? relativePath.slice(1) : relativePath);
|
|
231
|
+
const resolved = path.resolve(normalized);
|
|
232
|
+
const rootResolved = path.resolve(rootDir);
|
|
233
|
+
if (resolved === rootResolved || resolved.startsWith(rootResolved + path.sep)) {
|
|
234
|
+
return resolved;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
/**
|
|
195
241
|
* OneBun Application
|
|
196
242
|
*/
|
|
@@ -210,6 +256,7 @@ export class OneBunApplication {
|
|
|
210
256
|
private wsHandler: WsHandler | null = null;
|
|
211
257
|
private queueService: QueueService | null = null;
|
|
212
258
|
private queueAdapter: QueueAdapter | null = null;
|
|
259
|
+
private queueServiceProxy: QueueServiceProxy | null = null;
|
|
213
260
|
// Docs (OpenAPI/Swagger) - generated on start()
|
|
214
261
|
private openApiSpec: Record<string, unknown> | null = null;
|
|
215
262
|
private swaggerHtml: string | null = null;
|
|
@@ -364,11 +411,11 @@ export class OneBunApplication {
|
|
|
364
411
|
* const port = app.getConfigValue('server.port'); // number
|
|
365
412
|
* const host = app.getConfigValue('server.host'); // string
|
|
366
413
|
*/
|
|
367
|
-
getConfigValue<P extends DeepPaths<OneBunAppConfig>>(
|
|
414
|
+
getConfigValue<P extends DeepPaths<OneBunAppConfig>>(pathKey: P): DeepValue<OneBunAppConfig, P>;
|
|
368
415
|
/** Fallback for dynamic paths */
|
|
369
|
-
getConfigValue<T = unknown>(
|
|
370
|
-
getConfigValue(
|
|
371
|
-
return this.getConfig().get(
|
|
416
|
+
getConfigValue<T = unknown>(pathKey: string): T;
|
|
417
|
+
getConfigValue(pathKey: string): unknown {
|
|
418
|
+
return this.getConfig().get(pathKey);
|
|
372
419
|
}
|
|
373
420
|
|
|
374
421
|
/**
|
|
@@ -441,6 +488,16 @@ export class OneBunApplication {
|
|
|
441
488
|
// so services can safely use this.config.get() in their constructors
|
|
442
489
|
this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
|
|
443
490
|
|
|
491
|
+
// Register QueueService proxy in DI so controllers/providers/middleware can inject QueueService.
|
|
492
|
+
// After initializeQueue(), setDelegate(real) is called when queue is enabled.
|
|
493
|
+
this.queueServiceProxy = new QueueServiceProxy();
|
|
494
|
+
if (this.ensureModule().registerService) {
|
|
495
|
+
this.ensureModule().registerService!(
|
|
496
|
+
QueueServiceTag as Context.Tag<unknown, QueueService>,
|
|
497
|
+
this.queueServiceProxy as unknown as QueueService,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
444
501
|
// Start metrics collection if enabled
|
|
445
502
|
if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
|
|
446
503
|
this.metricsService.startSystemMetricsCollection();
|
|
@@ -901,6 +958,69 @@ export class OneBunApplication {
|
|
|
901
958
|
|
|
902
959
|
drain() { /* no-op */ },
|
|
903
960
|
};
|
|
961
|
+
|
|
962
|
+
// Static file serving: resolve root and setup existence cache (CacheService or in-memory Map)
|
|
963
|
+
const staticOpts = this.options.static;
|
|
964
|
+
let staticRootResolved: string | null = null;
|
|
965
|
+
let staticPathPrefix: string | undefined;
|
|
966
|
+
let staticFallbackFile: string | undefined;
|
|
967
|
+
let staticCacheTtlMs = 0;
|
|
968
|
+
// Cache interface: get(key) => value | undefined, set(key, value, ttlMs)
|
|
969
|
+
type StaticExistsCache = {
|
|
970
|
+
get(key: string): Promise<boolean | undefined>;
|
|
971
|
+
set(key: string, value: boolean, ttlMs: number): Promise<void>;
|
|
972
|
+
};
|
|
973
|
+
let staticExistsCache: StaticExistsCache | null = null;
|
|
974
|
+
|
|
975
|
+
if (staticOpts?.root) {
|
|
976
|
+
staticRootResolved = path.resolve(staticOpts.root);
|
|
977
|
+
staticPathPrefix = staticOpts.pathPrefix;
|
|
978
|
+
staticFallbackFile = staticOpts.fallbackFile;
|
|
979
|
+
staticCacheTtlMs = staticOpts.fileExistenceCacheTtlMs ?? DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS;
|
|
980
|
+
|
|
981
|
+
if (staticCacheTtlMs > 0) {
|
|
982
|
+
if (cacheServiceClass) {
|
|
983
|
+
try {
|
|
984
|
+
const cacheService = this.ensureModule().getServiceByClass?.(cacheServiceClass);
|
|
985
|
+
if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {
|
|
986
|
+
staticExistsCache = {
|
|
987
|
+
get: (key: string) => cacheService.get(STATIC_EXISTS_CACHE_PREFIX + key),
|
|
988
|
+
set: (key: string, value: boolean, ttlMs: number) =>
|
|
989
|
+
cacheService.set(STATIC_EXISTS_CACHE_PREFIX + key, value, { ttl: ttlMs }),
|
|
990
|
+
};
|
|
991
|
+
this.logger.debug('Static file existence cache using CacheService');
|
|
992
|
+
}
|
|
993
|
+
} catch {
|
|
994
|
+
// CacheService not in module, use fallback
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (!staticExistsCache) {
|
|
998
|
+
const map = new Map<string, { exists: boolean; expiresAt: number }>();
|
|
999
|
+
staticExistsCache = {
|
|
1000
|
+
async get(key: string): Promise<boolean | undefined> {
|
|
1001
|
+
const entry = map.get(key);
|
|
1002
|
+
if (!entry) {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
if (staticCacheTtlMs > 0 && Date.now() > entry.expiresAt) {
|
|
1006
|
+
map.delete(key);
|
|
1007
|
+
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return entry.exists;
|
|
1012
|
+
},
|
|
1013
|
+
async set(key: string, value: boolean, ttlMs: number): Promise<void> {
|
|
1014
|
+
map.set(key, {
|
|
1015
|
+
exists: value,
|
|
1016
|
+
expiresAt: ttlMs > 0 ? Date.now() + ttlMs : Number.MAX_SAFE_INTEGER,
|
|
1017
|
+
});
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
this.logger.debug('Static file existence cache using in-memory Map');
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
904
1024
|
|
|
905
1025
|
this.server = Bun.serve<WsClientData>({
|
|
906
1026
|
port: this.options.port,
|
|
@@ -920,8 +1040,8 @@ export class OneBunApplication {
|
|
|
920
1040
|
const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
|
|
921
1041
|
|
|
922
1042
|
const url = new URL(req.url);
|
|
923
|
-
const
|
|
924
|
-
const isSocketIoPath = socketioEnabled &&
|
|
1043
|
+
const requestPath = normalizePath(url.pathname);
|
|
1044
|
+
const isSocketIoPath = socketioEnabled && requestPath.startsWith(socketioPath);
|
|
925
1045
|
if (upgradeHeader === 'websocket' || isSocketIoPath) {
|
|
926
1046
|
const response = await app.wsHandler.handleUpgrade(req, server);
|
|
927
1047
|
if (response === undefined) {
|
|
@@ -932,6 +1052,60 @@ export class OneBunApplication {
|
|
|
932
1052
|
}
|
|
933
1053
|
}
|
|
934
1054
|
|
|
1055
|
+
// Static file serving (GET/HEAD only)
|
|
1056
|
+
if (staticRootResolved && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
1057
|
+
const requestPath = normalizePath(new URL(req.url).pathname);
|
|
1058
|
+
const prefix = staticPathPrefix ?? '/';
|
|
1059
|
+
const hasPrefix = prefix !== '' && prefix !== '/';
|
|
1060
|
+
if (hasPrefix && !requestPath.startsWith(prefix)) {
|
|
1061
|
+
return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
|
|
1062
|
+
}
|
|
1063
|
+
const relativePath = hasPrefix ? requestPath.slice(prefix.length) || '/' : requestPath;
|
|
1064
|
+
const resolvedPath = resolvePathUnderRoot(staticRootResolved, relativePath);
|
|
1065
|
+
if (resolvedPath === null) {
|
|
1066
|
+
return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const cache = staticCacheTtlMs > 0 ? staticExistsCache : null;
|
|
1070
|
+
const cacheKey = resolvedPath;
|
|
1071
|
+
if (cache) {
|
|
1072
|
+
const cached = await cache.get(cacheKey);
|
|
1073
|
+
if (cached === true) {
|
|
1074
|
+
return new Response(Bun.file(resolvedPath));
|
|
1075
|
+
}
|
|
1076
|
+
if (cached === false) {
|
|
1077
|
+
if (staticFallbackFile) {
|
|
1078
|
+
const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
|
|
1079
|
+
if (fallbackResolved !== null) {
|
|
1080
|
+
return new Response(Bun.file(fallbackResolved));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const file = Bun.file(resolvedPath);
|
|
1089
|
+
const exists = await file.exists();
|
|
1090
|
+
if (cache && staticCacheTtlMs > 0) {
|
|
1091
|
+
await cache.set(cacheKey, exists, staticCacheTtlMs);
|
|
1092
|
+
}
|
|
1093
|
+
if (exists) {
|
|
1094
|
+
return new Response(file);
|
|
1095
|
+
}
|
|
1096
|
+
if (staticFallbackFile) {
|
|
1097
|
+
const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
|
|
1098
|
+
if (fallbackResolved !== null) {
|
|
1099
|
+
const fallbackFile = Bun.file(fallbackResolved);
|
|
1100
|
+
if (await fallbackFile.exists()) {
|
|
1101
|
+
return new Response(fallbackFile);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
935
1109
|
// 404 for everything not matched by routes
|
|
936
1110
|
return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
|
|
937
1111
|
},
|
|
@@ -953,6 +1127,9 @@ export class OneBunApplication {
|
|
|
953
1127
|
}
|
|
954
1128
|
|
|
955
1129
|
this.logger.info(`Server started on http://${this.options.host}:${this.options.port}`);
|
|
1130
|
+
if (staticRootResolved) {
|
|
1131
|
+
this.logger.info(`Static files served from ${staticRootResolved}`);
|
|
1132
|
+
}
|
|
956
1133
|
if (this.metricsService) {
|
|
957
1134
|
this.logger.info(
|
|
958
1135
|
`Metrics available at http://${this.options.host}:${this.options.port}${metricsPath}`,
|
|
@@ -1481,6 +1658,7 @@ export class OneBunApplication {
|
|
|
1481
1658
|
await this.queueService.stop();
|
|
1482
1659
|
this.queueService = null;
|
|
1483
1660
|
}
|
|
1661
|
+
this.queueServiceProxy?.setDelegate(null);
|
|
1484
1662
|
|
|
1485
1663
|
// Disconnect queue adapter
|
|
1486
1664
|
if (this.queueAdapter) {
|
|
@@ -1542,43 +1720,57 @@ export class OneBunApplication {
|
|
|
1542
1720
|
}
|
|
1543
1721
|
|
|
1544
1722
|
// Create the appropriate adapter
|
|
1545
|
-
const
|
|
1546
|
-
|
|
1547
|
-
if (
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1723
|
+
const adapterOpt = queueOptions?.adapter ?? 'memory';
|
|
1724
|
+
|
|
1725
|
+
if (typeof adapterOpt === 'function') {
|
|
1726
|
+
// Custom adapter constructor (e.g. NATS JetStream)
|
|
1727
|
+
const adapterCtor = adapterOpt;
|
|
1728
|
+
this.queueAdapter = new adapterCtor(queueOptions?.options);
|
|
1729
|
+
await this.queueAdapter.connect();
|
|
1730
|
+
this.logger.info(`Queue system initialized with custom adapter: ${this.queueAdapter.name}`);
|
|
1731
|
+
} else {
|
|
1732
|
+
const adapterType = adapterOpt;
|
|
1733
|
+
if (adapterType === 'memory') {
|
|
1734
|
+
this.queueAdapter = new InMemoryQueueAdapter();
|
|
1735
|
+
this.logger.info('Queue system initialized with in-memory adapter');
|
|
1736
|
+
} else if (adapterType === 'redis') {
|
|
1737
|
+
const redisOptions = queueOptions?.redis ?? {};
|
|
1738
|
+
if (redisOptions.useSharedProvider !== false) {
|
|
1739
|
+
// Use shared Redis provider
|
|
1740
|
+
this.queueAdapter = new RedisQueueAdapter({
|
|
1741
|
+
useSharedClient: true,
|
|
1742
|
+
keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
|
|
1743
|
+
});
|
|
1744
|
+
this.logger.info('Queue system initialized with Redis adapter (shared provider)');
|
|
1745
|
+
} else if (redisOptions.url) {
|
|
1746
|
+
// Create dedicated Redis connection
|
|
1747
|
+
this.queueAdapter = new RedisQueueAdapter({
|
|
1748
|
+
useSharedClient: false,
|
|
1749
|
+
url: redisOptions.url,
|
|
1750
|
+
keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
|
|
1751
|
+
});
|
|
1752
|
+
this.logger.info('Queue system initialized with Redis adapter (dedicated connection)');
|
|
1753
|
+
} else {
|
|
1754
|
+
throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
|
|
1755
|
+
}
|
|
1567
1756
|
} else {
|
|
1568
|
-
throw new Error(
|
|
1757
|
+
throw new Error(`Unknown queue adapter type: ${adapterType}`);
|
|
1569
1758
|
}
|
|
1570
|
-
} else {
|
|
1571
|
-
throw new Error(`Unknown queue adapter type: ${adapterType}`);
|
|
1572
|
-
}
|
|
1573
1759
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1760
|
+
// Connect the adapter
|
|
1761
|
+
await this.queueAdapter.connect();
|
|
1762
|
+
}
|
|
1576
1763
|
|
|
1577
1764
|
// Create queue service with config
|
|
1578
|
-
|
|
1579
|
-
adapter:
|
|
1580
|
-
|
|
1581
|
-
|
|
1765
|
+
const queueServiceConfig: QueueConfig = {
|
|
1766
|
+
adapter:
|
|
1767
|
+
typeof adapterOpt === 'function' ? this.queueAdapter.type : adapterOpt,
|
|
1768
|
+
options:
|
|
1769
|
+
typeof adapterOpt === 'function'
|
|
1770
|
+
? (queueOptions?.options as Record<string, unknown> | undefined)
|
|
1771
|
+
: queueOptions?.redis,
|
|
1772
|
+
};
|
|
1773
|
+
this.queueService = new QueueService(queueServiceConfig);
|
|
1582
1774
|
|
|
1583
1775
|
// Initialize with the adapter
|
|
1584
1776
|
await this.queueService.initialize(this.queueAdapter);
|
|
@@ -1603,6 +1795,9 @@ export class OneBunApplication {
|
|
|
1603
1795
|
// Start the queue service
|
|
1604
1796
|
await this.queueService.start();
|
|
1605
1797
|
this.logger.info('Queue service started');
|
|
1798
|
+
|
|
1799
|
+
// Wire the real QueueService into the DI proxy so injected consumers use it
|
|
1800
|
+
this.queueServiceProxy?.setDelegate(this.queueService);
|
|
1606
1801
|
}
|
|
1607
1802
|
|
|
1608
1803
|
/**
|
|
@@ -181,6 +181,21 @@ describe('MultiServiceApplication', () => {
|
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
describe('queue option', () => {
|
|
185
|
+
test('should accept queue option and pass it to child applications', () => {
|
|
186
|
+
const app = new MultiServiceApplication({
|
|
187
|
+
services: {
|
|
188
|
+
serviceA: { module: TestModuleA, port: 3001 },
|
|
189
|
+
},
|
|
190
|
+
queue: {
|
|
191
|
+
enabled: true,
|
|
192
|
+
adapter: 'memory',
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
expect(app).toBeDefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
184
199
|
describe('filtering configuration', () => {
|
|
185
200
|
test('should accept enabledServices option', () => {
|
|
186
201
|
const app = new MultiServiceApplication({
|
|
@@ -219,6 +219,8 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
|
|
|
219
219
|
...mergedOptions.tracing,
|
|
220
220
|
serviceName: name,
|
|
221
221
|
},
|
|
222
|
+
queue: this.options.queue,
|
|
223
|
+
static: mergedOptions.static ?? serviceConfig.static,
|
|
222
224
|
});
|
|
223
225
|
|
|
224
226
|
this.applications.set(name, app);
|
|
@@ -30,7 +30,7 @@ export type TracingOptions = NonNullable<ApplicationOptions['tracing']>;
|
|
|
30
30
|
* Any new shared options should be added to ApplicationOptions first.
|
|
31
31
|
*/
|
|
32
32
|
export interface BaseServiceOptions
|
|
33
|
-
extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware'> {
|
|
33
|
+
extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware' | 'static'> {
|
|
34
34
|
/**
|
|
35
35
|
* Add service name as prefix to all routes.
|
|
36
36
|
* When true, the service name will be used as routePrefix.
|
|
@@ -132,4 +132,10 @@ export interface MultiServiceApplicationOptions<TServices extends ServicesMap =
|
|
|
132
132
|
* ```
|
|
133
133
|
*/
|
|
134
134
|
externalServiceUrls?: Partial<Record<keyof TServices, string>>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Queue configuration applied to all services.
|
|
138
|
+
* When set, each service's OneBunApplication receives this queue config.
|
|
139
|
+
*/
|
|
140
|
+
queue?: ApplicationOptions['queue'];
|
|
135
141
|
}
|
|
@@ -1648,7 +1648,7 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
|
|
|
1648
1648
|
|
|
1649
1649
|
describe('Controller Lifecycle Hooks', () => {
|
|
1650
1650
|
/**
|
|
1651
|
-
* @source docs/api/
|
|
1651
|
+
* @source docs/api/services.md#lifecycle-hooks (controllers support the same hooks)
|
|
1652
1652
|
*/
|
|
1653
1653
|
it('should implement lifecycle hooks in controllers', () => {
|
|
1654
1654
|
// From docs: Controller lifecycle hooks example
|
|
@@ -2406,6 +2406,34 @@ describe('OneBunApplication (docs/api/core.md)', () => {
|
|
|
2406
2406
|
const app = new OneBunApplication(AppModule, { tracing: tracingOptions, loggerLayer: makeMockLoggerLayer() });
|
|
2407
2407
|
expect(app).toBeDefined();
|
|
2408
2408
|
});
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* @source docs/api/core.md#staticapplicationoptions
|
|
2412
|
+
*/
|
|
2413
|
+
it('should accept static file serving configuration (SPA on same host)', async () => {
|
|
2414
|
+
const fs = await import('node:fs');
|
|
2415
|
+
const path = await import('node:path');
|
|
2416
|
+
const os = await import('node:os');
|
|
2417
|
+
|
|
2418
|
+
const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'onebun-docs-static-'));
|
|
2419
|
+
try {
|
|
2420
|
+
fs.writeFileSync(path.join(tmpDir, 'index.html'), '<!DOCTYPE html><html><body>SPA</body></html>', 'utf8');
|
|
2421
|
+
|
|
2422
|
+
@Module({ controllers: [] })
|
|
2423
|
+
class AppModule {}
|
|
2424
|
+
|
|
2425
|
+
// From docs: Static files (SPA on same host)
|
|
2426
|
+
const app = new OneBunApplication(AppModule, {
|
|
2427
|
+
loggerLayer: makeMockLoggerLayer(),
|
|
2428
|
+
static: { root: tmpDir, fallbackFile: 'index.html' },
|
|
2429
|
+
});
|
|
2430
|
+
expect(app).toBeDefined();
|
|
2431
|
+
await app.start();
|
|
2432
|
+
await app.stop();
|
|
2433
|
+
} finally {
|
|
2434
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2409
2437
|
});
|
|
2410
2438
|
|
|
2411
2439
|
describe('MultiServiceApplication (docs/api/core.md)', () => {
|
package/src/index.ts
CHANGED
package/src/module/module.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
isGlobalModule,
|
|
23
23
|
registerControllerDependencies,
|
|
24
24
|
} from '../decorators/decorators';
|
|
25
|
+
import { QueueService, QueueServiceTag } from '../queue';
|
|
25
26
|
import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
|
|
26
27
|
import { isWebSocketGateway } from '../websocket/ws-decorators';
|
|
27
28
|
|
|
@@ -616,6 +617,16 @@ export class OneBunModule implements ModuleInstance {
|
|
|
616
617
|
* Resolve dependency by type (constructor function)
|
|
617
618
|
*/
|
|
618
619
|
private resolveDependencyByType(type: Function): unknown {
|
|
620
|
+
// QueueService is registered by tag (QueueServiceTag) before setup(); resolve by tag
|
|
621
|
+
if (type === QueueService) {
|
|
622
|
+
const byTag = this.serviceInstances.get(
|
|
623
|
+
QueueServiceTag as Context.Tag<unknown, unknown>,
|
|
624
|
+
);
|
|
625
|
+
if (byTag !== undefined) {
|
|
626
|
+
return byTag;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
619
630
|
// Find service instance that matches the type
|
|
620
631
|
const serviceInstance = Array.from(this.serviceInstances.values()).find((instance) => {
|
|
621
632
|
if (!instance) {
|
|
@@ -1041,6 +1052,13 @@ export class OneBunModule implements ModuleInstance {
|
|
|
1041
1052
|
return this.rootLayer;
|
|
1042
1053
|
}
|
|
1043
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* Register a service instance by tag (e.g. before setup() for application-provided services like QueueService proxy).
|
|
1057
|
+
*/
|
|
1058
|
+
registerService<T>(tag: Context.Tag<unknown, T>, instance: T): void {
|
|
1059
|
+
this.serviceInstances.set(tag as Context.Tag<unknown, unknown>, instance);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1044
1062
|
/**
|
|
1045
1063
|
* Create a module from class
|
|
1046
1064
|
* @param moduleClass - The module class
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
MessageAnyGuard,
|
|
36
36
|
createMessageGuard,
|
|
37
37
|
type Message,
|
|
38
|
+
type QueueAdapter,
|
|
38
39
|
CronExpression,
|
|
39
40
|
parseCronExpression,
|
|
40
41
|
getNextRun,
|
|
@@ -113,9 +114,9 @@ describe('Setup Section Examples (docs/api/queue.md)', () => {
|
|
|
113
114
|
* @source docs/api/queue.md#quick-start
|
|
114
115
|
*/
|
|
115
116
|
describe('Quick Start Example (docs/api/queue.md)', () => {
|
|
116
|
-
it('should define
|
|
117
|
-
// From docs/api/queue.md: Quick Start
|
|
118
|
-
class
|
|
117
|
+
it('should define controller with queue decorators', () => {
|
|
118
|
+
// From docs/api/queue.md: Quick Start (queue handlers must be in controllers)
|
|
119
|
+
class EventProcessor {
|
|
119
120
|
@OnQueueReady()
|
|
120
121
|
onReady() {
|
|
121
122
|
// console.log('Queue connected');
|
|
@@ -134,19 +135,19 @@ describe('Quick Start Example (docs/api/queue.md)', () => {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
// Verify decorators are registered
|
|
137
|
-
expect(hasQueueDecorators(
|
|
138
|
+
expect(hasQueueDecorators(EventProcessor)).toBe(true);
|
|
138
139
|
|
|
139
|
-
const subscriptions = getSubscribeMetadata(
|
|
140
|
+
const subscriptions = getSubscribeMetadata(EventProcessor);
|
|
140
141
|
expect(subscriptions.length).toBe(1);
|
|
141
142
|
expect(subscriptions[0].pattern).toBe('orders.created');
|
|
142
143
|
|
|
143
|
-
const cronJobs = getCronMetadata(
|
|
144
|
+
const cronJobs = getCronMetadata(EventProcessor);
|
|
144
145
|
expect(cronJobs.length).toBe(1);
|
|
145
146
|
expect(cronJobs[0].expression).toBe(CronExpression.EVERY_HOUR);
|
|
146
147
|
expect(cronJobs[0].options.pattern).toBe('cleanup.expired');
|
|
147
148
|
});
|
|
148
149
|
|
|
149
|
-
it('should define
|
|
150
|
+
it('should define controller with interval decorator', () => {
|
|
150
151
|
// From docs/api/queue.md: Quick Start - Interval example
|
|
151
152
|
class EventProcessor {
|
|
152
153
|
@Subscribe('orders.created')
|
|
@@ -389,9 +390,9 @@ describe('Message Guards Examples (docs/api/queue.md)', () => {
|
|
|
389
390
|
* @source docs/api/queue.md#lifecycle-decorators
|
|
390
391
|
*/
|
|
391
392
|
describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
|
|
392
|
-
it('should define lifecycle handlers', () => {
|
|
393
|
-
// From docs/api/queue.md: Lifecycle Decorators
|
|
394
|
-
class
|
|
393
|
+
it('should define lifecycle handlers on controller', () => {
|
|
394
|
+
// From docs/api/queue.md: Lifecycle Decorators (handlers must be in controllers)
|
|
395
|
+
class EventProcessor {
|
|
395
396
|
@OnQueueReady()
|
|
396
397
|
handleReady() {
|
|
397
398
|
// console.log('Queue connected');
|
|
@@ -418,8 +419,67 @@ describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
|
|
|
418
419
|
}
|
|
419
420
|
}
|
|
420
421
|
|
|
421
|
-
// Class
|
|
422
|
-
expect(
|
|
422
|
+
// Class with lifecycle handlers only; hasQueueDecorators checks Subscribe/Cron/Interval/Timeout only
|
|
423
|
+
expect(EventProcessor).toBeDefined();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* @source docs/api/queue.md#custom-adapter-nats-jetstream
|
|
429
|
+
*/
|
|
430
|
+
describe('Custom adapter NATS JetStream (docs/api/queue.md)', () => {
|
|
431
|
+
it('should use custom adapter constructor with options', async () => {
|
|
432
|
+
// From docs/api/queue.md: Custom adapter: NATS JetStream
|
|
433
|
+
// Minimal adapter class that implements QueueAdapter for use with queue: { adapter, options }
|
|
434
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
435
|
+
class NatsJetStreamAdapter implements QueueAdapter {
|
|
436
|
+
readonly name = 'nats-jetstream';
|
|
437
|
+
readonly type = 'jetstream';
|
|
438
|
+
constructor(private opts: { servers: string; stream?: string }) {}
|
|
439
|
+
async connect(): Promise<void> {}
|
|
440
|
+
async disconnect(): Promise<void> {}
|
|
441
|
+
isConnected(): boolean {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
async publish(): Promise<string> {
|
|
445
|
+
return '';
|
|
446
|
+
}
|
|
447
|
+
async publishBatch(): Promise<string[]> {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
async subscribe(): Promise<import('./types').Subscription> {
|
|
451
|
+
return {
|
|
452
|
+
async unsubscribe() {},
|
|
453
|
+
pause() {},
|
|
454
|
+
resume() {},
|
|
455
|
+
pattern: '',
|
|
456
|
+
isActive: true,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async addScheduledJob(): Promise<void> {}
|
|
460
|
+
async removeScheduledJob(): Promise<boolean> {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
async getScheduledJobs(): Promise<import('./types').ScheduledJobInfo[]> {
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
supports(): boolean {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
on(): void {}
|
|
470
|
+
off(): void {}
|
|
471
|
+
}
|
|
472
|
+
/* eslint-enable @typescript-eslint/no-empty-function */
|
|
473
|
+
|
|
474
|
+
const adapter = new NatsJetStreamAdapter({
|
|
475
|
+
servers: 'nats://localhost:4222',
|
|
476
|
+
stream: 'EVENTS',
|
|
477
|
+
});
|
|
478
|
+
await adapter.connect();
|
|
479
|
+
expect(adapter.name).toBe('nats-jetstream');
|
|
480
|
+
expect(adapter.type).toBe('jetstream');
|
|
481
|
+
expect(adapter.isConnected()).toBe(true);
|
|
482
|
+
await adapter.disconnect();
|
|
423
483
|
});
|
|
424
484
|
});
|
|
425
485
|
|
package/src/queue/index.ts
CHANGED
|
@@ -113,6 +113,10 @@ export {
|
|
|
113
113
|
createQueueService,
|
|
114
114
|
resolveAdapterType,
|
|
115
115
|
} from './queue.service';
|
|
116
|
+
export {
|
|
117
|
+
QueueServiceProxy,
|
|
118
|
+
QUEUE_NOT_ENABLED_ERROR_MESSAGE,
|
|
119
|
+
} from './queue-service-proxy';
|
|
116
120
|
|
|
117
121
|
// Adapters (re-export from adapters folder)
|
|
118
122
|
export * from './adapters';
|