@onebun/core 0.1.4 → 0.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": "workspace:^",
45
- "@onebun/envs": "workspace:^",
46
- "@onebun/metrics": "workspace:^",
47
- "@onebun/requests": "workspace:^",
48
- "@onebun/trace": "workspace:^"
44
+ "@onebun/logger": "^0.1.3",
45
+ "@onebun/envs": "^0.1.2",
46
+ "@onebun/metrics": "^0.1.3",
47
+ "@onebun/requests": "^0.1.2",
48
+ "@onebun/trace": "^0.1.3"
49
49
  },
50
50
  "devDependencies": {
51
51
  "bun-types": "1.2.2"
@@ -1000,6 +1000,243 @@ describe('OneBunApplication', () => {
1000
1000
  title: 'Post 123 by User 42',
1001
1001
  });
1002
1002
  });
1003
+
1004
+ test('should handle trailing slashes - route without trailing slash matches request with trailing slash', async () => {
1005
+ @Controller('/api')
1006
+ class ApiController extends BaseController {
1007
+ @Get('/users/:page')
1008
+ async getUsers(@Param('page') page: string) {
1009
+ return { users: ['Alice', 'Bob'], page: parseInt(page) };
1010
+ }
1011
+ }
1012
+
1013
+ @Module({
1014
+ controllers: [ApiController],
1015
+ })
1016
+ class TestModule {}
1017
+
1018
+ const app = createTestApp(TestModule);
1019
+ await app.start();
1020
+
1021
+ // Request WITH trailing slash should match route WITHOUT trailing slash
1022
+ const request = new Request('http://localhost:3000/api/users/1/', {
1023
+ method: 'GET',
1024
+ });
1025
+
1026
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1027
+ const response = await (mockServer as any).fetchHandler(request);
1028
+
1029
+ // Should return 200, not 404 (route should be found)
1030
+ expect(response).toBeInstanceOf(Response);
1031
+ expect(response.status).toBe(200);
1032
+
1033
+ const body = await response.json();
1034
+ expect(body.result).toEqual({ users: ['Alice', 'Bob'], page: 1 });
1035
+ });
1036
+
1037
+ test('should handle trailing slashes - both with and without slash return same result', async () => {
1038
+ @Controller('/api')
1039
+ class ApiController extends BaseController {
1040
+ @Get('/items/:category')
1041
+ async getItems(@Param('category') category: string) {
1042
+ return { items: [1, 2, 3], category };
1043
+ }
1044
+ }
1045
+
1046
+ @Module({
1047
+ controllers: [ApiController],
1048
+ })
1049
+ class TestModule {}
1050
+
1051
+ const app = createTestApp(TestModule);
1052
+ await app.start();
1053
+
1054
+ // Request WITHOUT trailing slash
1055
+ const requestWithout = new Request('http://localhost:3000/api/items/electronics', {
1056
+ method: 'GET',
1057
+ });
1058
+
1059
+ // Request WITH trailing slash
1060
+ const requestWith = new Request('http://localhost:3000/api/items/electronics/', {
1061
+ method: 'GET',
1062
+ });
1063
+
1064
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1065
+ const responseWithout = await (mockServer as any).fetchHandler(requestWithout);
1066
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1067
+ const responseWith = await (mockServer as any).fetchHandler(requestWith);
1068
+
1069
+ // Both should be valid Response objects
1070
+ expect(responseWithout).toBeInstanceOf(Response);
1071
+ expect(responseWith).toBeInstanceOf(Response);
1072
+
1073
+ // Both should return 200 (not 404)
1074
+ expect(responseWithout.status).toBe(200);
1075
+ expect(responseWith.status).toBe(200);
1076
+
1077
+ const bodyWithout = await responseWithout.json();
1078
+ const bodyWith = await responseWith.json();
1079
+
1080
+ expect(bodyWithout.result).toEqual(bodyWith.result);
1081
+ });
1082
+
1083
+ test('should handle trailing slashes with route parameters', async () => {
1084
+ @Controller('/api')
1085
+ class ApiController extends BaseController {
1086
+ @Get('/users/:id')
1087
+ async getUser(@Param('id') id: string) {
1088
+ return { id: parseInt(id) };
1089
+ }
1090
+ }
1091
+
1092
+ @Module({
1093
+ controllers: [ApiController],
1094
+ })
1095
+ class TestModule {}
1096
+
1097
+ const app = createTestApp(TestModule);
1098
+ await app.start();
1099
+
1100
+ // Request WITH trailing slash on parameterized route
1101
+ const request = new Request('http://localhost:3000/api/users/123/', {
1102
+ method: 'GET',
1103
+ });
1104
+
1105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1106
+ const response = await (mockServer as any).fetchHandler(request);
1107
+ const body = await response.json();
1108
+
1109
+ expect(response.status).toBe(200);
1110
+ expect(body.result).toEqual({ id: 123 });
1111
+ });
1112
+
1113
+ test('should handle root path correctly (no trailing slash removal)', async () => {
1114
+ @Controller('/root')
1115
+ class RootController extends BaseController {
1116
+ @Get('/:id')
1117
+ async getById(@Param('id') id: string) {
1118
+ return { message: 'root', id };
1119
+ }
1120
+ }
1121
+
1122
+ @Module({
1123
+ controllers: [RootController],
1124
+ })
1125
+ class TestModule {}
1126
+
1127
+ const app = createTestApp(TestModule);
1128
+ await app.start();
1129
+
1130
+ // Test that trailing slash is handled correctly even for short paths
1131
+ const request = new Request('http://localhost:3000/root/42/', {
1132
+ method: 'GET',
1133
+ });
1134
+
1135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1136
+ const response = await (mockServer as any).fetchHandler(request);
1137
+
1138
+ expect(response).toBeInstanceOf(Response);
1139
+ expect(response.status).toBe(200);
1140
+
1141
+ const body = await response.json();
1142
+ expect(body.result).toEqual({ message: 'root', id: '42' });
1143
+ });
1144
+
1145
+ test('should handle exact root path with trailing slash', async () => {
1146
+ @Controller('/health')
1147
+ class HealthController extends BaseController {
1148
+ @Get('/:type')
1149
+ async check(@Param('type') type: string) {
1150
+ return { status: 'ok', type };
1151
+ }
1152
+ }
1153
+
1154
+ @Module({
1155
+ controllers: [HealthController],
1156
+ })
1157
+ class TestModule {}
1158
+
1159
+ const app = createTestApp(TestModule);
1160
+ await app.start();
1161
+
1162
+ // Root path "/" should remain "/" after normalization
1163
+ const requestWithSlash = new Request('http://localhost:3000/health/live/', {
1164
+ method: 'GET',
1165
+ });
1166
+
1167
+ const requestWithoutSlash = new Request('http://localhost:3000/health/live', {
1168
+ method: 'GET',
1169
+ });
1170
+
1171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1172
+ const responseWith = await (mockServer as any).fetchHandler(requestWithSlash);
1173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1174
+ const responseWithout = await (mockServer as any).fetchHandler(requestWithoutSlash);
1175
+
1176
+ // Both should work identically
1177
+ expect(responseWith.status).toBe(200);
1178
+ expect(responseWithout.status).toBe(200);
1179
+
1180
+ const bodyWith = await responseWith.json();
1181
+ const bodyWithout = await responseWithout.json();
1182
+ expect(bodyWith.result).toEqual(bodyWithout.result);
1183
+ });
1184
+
1185
+ test('should normalize metrics route labels - trailing slash requests use same label as non-trailing', async () => {
1186
+ @Controller('/api')
1187
+ class ApiController extends BaseController {
1188
+ @Get('/data')
1189
+ async getData() {
1190
+ return { data: 'test' };
1191
+ }
1192
+ }
1193
+
1194
+ @Module({
1195
+ controllers: [ApiController],
1196
+ })
1197
+ class TestModule {}
1198
+
1199
+ const app = createTestApp(TestModule, {
1200
+ metrics: { path: '/metrics' },
1201
+ });
1202
+
1203
+ // Track recorded metrics
1204
+ const recordedMetrics: Array<{ route: string }> = [];
1205
+
1206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1207
+ const mockMetricsService: any = {
1208
+ getMetrics: mock(() => Promise.resolve('# metrics data')),
1209
+ getContentType: mock(() => 'text/plain'),
1210
+ startSystemMetricsCollection: mock(),
1211
+ recordHttpRequest: mock((data: { route: string }) => {
1212
+ recordedMetrics.push({ route: data.route });
1213
+ }),
1214
+ };
1215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1216
+ (app as any).metricsService = mockMetricsService;
1217
+
1218
+ await app.start();
1219
+
1220
+ // Make requests with both trailing and non-trailing slash
1221
+ const requestWithout = new Request('http://localhost:3000/api/data', {
1222
+ method: 'GET',
1223
+ });
1224
+ const requestWith = new Request('http://localhost:3000/api/data/', {
1225
+ method: 'GET',
1226
+ });
1227
+
1228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1229
+ await (mockServer as any).fetchHandler(requestWithout);
1230
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1231
+ await (mockServer as any).fetchHandler(requestWith);
1232
+
1233
+ // Both requests should record metrics with the same route label (without trailing slash)
1234
+ expect(recordedMetrics.length).toBe(2);
1235
+ expect(recordedMetrics[0].route).toBe('/api/data');
1236
+ expect(recordedMetrics[1].route).toBe('/api/data');
1237
+ // Verify they are the same (no duplication due to trailing slash)
1238
+ expect(recordedMetrics[0].route).toBe(recordedMetrics[1].route);
1239
+ });
1003
1240
  });
1004
1241
 
1005
1242
  describe('Tracing integration', () => {
@@ -50,6 +50,21 @@ try {
50
50
  // Metrics module not available - this is optional
51
51
  }
52
52
 
53
+ // Conditionally import docs (optional dependency - not added to package.json to avoid circular deps)
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ let generateOpenApiSpec: any;
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ let generateSwaggerUiHtml: any;
58
+
59
+ try {
60
+ // eslint-disable-next-line import/no-extraneous-dependencies
61
+ const docsModule = require('@onebun/docs');
62
+ generateOpenApiSpec = docsModule.generateOpenApiSpec;
63
+ generateSwaggerUiHtml = docsModule.generateSwaggerUiHtml;
64
+ } catch {
65
+ // Docs module not available - this is optional
66
+ }
67
+
53
68
  // Import tracing modules directly
54
69
 
55
70
  // Global trace context for current request
@@ -63,6 +78,26 @@ function clearGlobalTraceContext(): void {
63
78
  }
64
79
  }
65
80
 
81
+ /**
82
+ * Normalize URL path by removing trailing slashes (except for root path).
83
+ * This ensures consistent route matching and metrics collection.
84
+ *
85
+ * @param path - The URL path to normalize
86
+ * @returns Normalized path without trailing slash
87
+ * @example
88
+ * normalizePath('/users/') // => '/users'
89
+ * normalizePath('/users') // => '/users'
90
+ * normalizePath('/') // => '/'
91
+ * normalizePath('/api/v1/') // => '/api/v1'
92
+ */
93
+ function normalizePath(path: string): string {
94
+ if (path === '/' || path.length <= 1) {
95
+ return path;
96
+ }
97
+
98
+ return path.endsWith('/') ? path.slice(0, -1) : path;
99
+ }
100
+
66
101
  /**
67
102
  * OneBun Application
68
103
  */
@@ -85,6 +120,9 @@ export class OneBunApplication {
85
120
  private wsHandler: WsHandler | null = null;
86
121
  private queueService: QueueService | null = null;
87
122
  private queueAdapter: QueueAdapter | null = null;
123
+ // Docs (OpenAPI/Swagger) - generated on start()
124
+ private openApiSpec: Record<string, unknown> | null = null;
125
+ private swaggerHtml: string | null = null;
88
126
 
89
127
  /**
90
128
  * Create application instance
@@ -287,6 +325,9 @@ export class OneBunApplication {
287
325
  // Initialize Queue system if configured or handlers exist
288
326
  await this.initializeQueue(controllers);
289
327
 
328
+ // Initialize Docs (OpenAPI/Swagger) if enabled and available
329
+ await this.initializeDocs(controllers);
330
+
290
331
  // Create a map of routes with metadata
291
332
  const routes = new Map<
292
333
  string,
@@ -384,6 +425,10 @@ export class OneBunApplication {
384
425
  // Get metrics path
385
426
  const metricsPath = this.options.metrics?.path || '/metrics';
386
427
 
428
+ // Get docs paths
429
+ const docsPath = this.options.docs?.path || '/docs';
430
+ const openApiPath = this.options.docs?.jsonPath || '/openapi.json';
431
+
387
432
  // Create server with proper context binding
388
433
  const app = this;
389
434
  const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
@@ -408,7 +453,10 @@ export class OneBunApplication {
408
453
  websocket: wsHandlers,
409
454
  async fetch(req, server) {
410
455
  const url = new URL(req.url);
411
- const path = url.pathname;
456
+ const rawPath = url.pathname;
457
+ // Normalize path to ensure consistent routing and metrics
458
+ // (removes trailing slash except for root path)
459
+ const path = normalizePath(rawPath);
412
460
  const method = req.method;
413
461
  const startTime = Date.now();
414
462
 
@@ -498,6 +546,29 @@ export class OneBunApplication {
498
546
  }
499
547
  }
500
548
 
549
+ // Handle docs endpoints (OpenAPI/Swagger)
550
+ if (app.options.docs?.enabled !== false && app.openApiSpec) {
551
+ // Serve Swagger UI HTML
552
+ if (path === docsPath && method === 'GET' && app.swaggerHtml) {
553
+ return new Response(app.swaggerHtml, {
554
+ headers: {
555
+ // eslint-disable-next-line @typescript-eslint/naming-convention
556
+ 'Content-Type': 'text/html; charset=utf-8',
557
+ },
558
+ });
559
+ }
560
+
561
+ // Serve OpenAPI JSON spec
562
+ if (path === openApiPath && method === 'GET') {
563
+ return new Response(JSON.stringify(app.openApiSpec, null, 2), {
564
+ headers: {
565
+ // eslint-disable-next-line @typescript-eslint/naming-convention
566
+ 'Content-Type': 'application/json',
567
+ },
568
+ });
569
+ }
570
+ }
571
+
501
572
  // Handle metrics endpoint
502
573
  if (path === metricsPath && method === 'GET' && app.metricsService) {
503
574
  try {
@@ -1122,6 +1193,80 @@ export class OneBunApplication {
1122
1193
  return this.queueService;
1123
1194
  }
1124
1195
 
1196
+ /**
1197
+ * Initialize the documentation system (OpenAPI/Swagger)
1198
+ */
1199
+ private async initializeDocs(controllers: Function[]): Promise<void> {
1200
+ const docsOptions = this.options.docs;
1201
+
1202
+ // Skip if docs are explicitly disabled or @onebun/docs is not available
1203
+ if (docsOptions?.enabled === false) {
1204
+ this.logger.debug('Documentation explicitly disabled');
1205
+
1206
+ return;
1207
+ }
1208
+
1209
+ if (!generateOpenApiSpec || !generateSwaggerUiHtml) {
1210
+ if (docsOptions?.enabled === true) {
1211
+ this.logger.warn(
1212
+ 'Documentation enabled but @onebun/docs module not available. Install with: bun add @onebun/docs',
1213
+ );
1214
+ } else {
1215
+ this.logger.debug('@onebun/docs module not available, documentation disabled');
1216
+ }
1217
+
1218
+ return;
1219
+ }
1220
+
1221
+ try {
1222
+ // Generate OpenAPI spec from controllers
1223
+ this.openApiSpec = generateOpenApiSpec(controllers, {
1224
+ title: docsOptions?.title || this.options.name || 'OneBun API',
1225
+ version: docsOptions?.version || '1.0.0',
1226
+ description: docsOptions?.description,
1227
+ });
1228
+
1229
+ // Add additional OpenAPI info if provided
1230
+ if (this.openApiSpec && docsOptions?.contact) {
1231
+ (this.openApiSpec.info as Record<string, unknown>).contact = docsOptions.contact;
1232
+ }
1233
+ if (this.openApiSpec && docsOptions?.license) {
1234
+ (this.openApiSpec.info as Record<string, unknown>).license = docsOptions.license;
1235
+ }
1236
+ if (this.openApiSpec && docsOptions?.externalDocs) {
1237
+ this.openApiSpec.externalDocs = docsOptions.externalDocs;
1238
+ }
1239
+ if (this.openApiSpec && docsOptions?.servers && docsOptions.servers.length > 0) {
1240
+ this.openApiSpec.servers = docsOptions.servers;
1241
+ }
1242
+
1243
+ // Generate Swagger UI HTML
1244
+ const openApiPath = docsOptions?.jsonPath || '/openapi.json';
1245
+ this.swaggerHtml = generateSwaggerUiHtml(openApiPath);
1246
+
1247
+ const docsPath = docsOptions?.path || '/docs';
1248
+ this.logger.info(
1249
+ `Documentation available at http://${this.options.host}:${this.options.port}${docsPath}`,
1250
+ );
1251
+ this.logger.info(
1252
+ `OpenAPI spec available at http://${this.options.host}:${this.options.port}${openApiPath}`,
1253
+ );
1254
+ } catch (error) {
1255
+ this.logger.error(
1256
+ 'Failed to initialize documentation:',
1257
+ error instanceof Error ? error : new Error(String(error)),
1258
+ );
1259
+ }
1260
+ }
1261
+
1262
+ /**
1263
+ * Get the OpenAPI specification
1264
+ * @returns The OpenAPI spec or null if not generated
1265
+ */
1266
+ getOpenApiSpec(): Record<string, unknown> | null {
1267
+ return this.openApiSpec;
1268
+ }
1269
+
1125
1270
  /**
1126
1271
  * Register signal handlers for graceful shutdown
1127
1272
  * Call this after start() to enable automatic shutdown on SIGTERM/SIGINT
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export {
33
33
  type WsStorageType,
34
34
  type WsStorageOptions,
35
35
  type WebSocketApplicationOptions,
36
+ // Docs types
37
+ type DocsApplicationOptions,
36
38
  } from './types';
37
39
 
38
40
  // Decorators and Metadata (exports Controller decorator, Module decorator, etc.)
package/src/types.ts CHANGED
@@ -277,6 +277,11 @@ export interface ApplicationOptions {
277
277
  */
278
278
  queue?: QueueApplicationOptions;
279
279
 
280
+ /**
281
+ * Documentation configuration (OpenAPI/Swagger)
282
+ */
283
+ docs?: DocsApplicationOptions;
284
+
280
285
  /**
281
286
  * Enable graceful shutdown on SIGTERM/SIGINT
282
287
  * When enabled, the application will cleanly shutdown on process signals,
@@ -347,6 +352,80 @@ export interface WebSocketApplicationOptions {
347
352
  maxPayload?: number;
348
353
  }
349
354
 
355
+ /**
356
+ * Documentation configuration for OneBunApplication
357
+ * Enables automatic OpenAPI spec generation and Swagger UI
358
+ */
359
+ export interface DocsApplicationOptions {
360
+ /**
361
+ * Enable/disable documentation endpoints
362
+ * @defaultValue true
363
+ */
364
+ enabled?: boolean;
365
+
366
+ /**
367
+ * Path for Swagger UI
368
+ * @defaultValue '/docs'
369
+ */
370
+ path?: string;
371
+
372
+ /**
373
+ * Path for OpenAPI JSON specification
374
+ * @defaultValue '/openapi.json'
375
+ */
376
+ jsonPath?: string;
377
+
378
+ /**
379
+ * API title for OpenAPI spec
380
+ * @defaultValue Application name or 'OneBun API'
381
+ */
382
+ title?: string;
383
+
384
+ /**
385
+ * API version for OpenAPI spec
386
+ * @defaultValue '1.0.0'
387
+ */
388
+ version?: string;
389
+
390
+ /**
391
+ * API description for OpenAPI spec
392
+ */
393
+ description?: string;
394
+
395
+ /**
396
+ * Contact information
397
+ */
398
+ contact?: {
399
+ name?: string;
400
+ email?: string;
401
+ url?: string;
402
+ };
403
+
404
+ /**
405
+ * License information
406
+ */
407
+ license?: {
408
+ name: string;
409
+ url?: string;
410
+ };
411
+
412
+ /**
413
+ * External documentation link
414
+ */
415
+ externalDocs?: {
416
+ description?: string;
417
+ url: string;
418
+ };
419
+
420
+ /**
421
+ * Server URLs for OpenAPI spec
422
+ */
423
+ servers?: Array<{
424
+ url: string;
425
+ description?: string;
426
+ }>;
427
+ }
428
+
350
429
  /**
351
430
  * HTTP method types
352
431
  */