@onebun/core 0.2.0 → 0.2.2

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.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -906,6 +906,42 @@ describe('OneBunApplication', () => {
906
906
  expect(Bun.serve).toHaveBeenCalled();
907
907
  });
908
908
 
909
+ test('should pass default idleTimeout (120s) to Bun.serve', async () => {
910
+ @Module({})
911
+ class TestModule {}
912
+
913
+ const app = createTestApp(TestModule);
914
+ await app.start();
915
+
916
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
917
+ const serveCall = (Bun.serve as any).mock.calls[0];
918
+ expect(serveCall[0].idleTimeout).toBe(120);
919
+ });
920
+
921
+ test('should pass custom idleTimeout to Bun.serve', async () => {
922
+ @Module({})
923
+ class TestModule {}
924
+
925
+ const app = createTestApp(TestModule, { idleTimeout: 60 });
926
+ await app.start();
927
+
928
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
929
+ const serveCall = (Bun.serve as any).mock.calls[0];
930
+ expect(serveCall[0].idleTimeout).toBe(60);
931
+ });
932
+
933
+ test('should pass idleTimeout: 0 to disable timeout', async () => {
934
+ @Module({})
935
+ class TestModule {}
936
+
937
+ const app = createTestApp(TestModule, { idleTimeout: 0 });
938
+ await app.start();
939
+
940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
941
+ const serveCall = (Bun.serve as any).mock.calls[0];
942
+ expect(serveCall[0].idleTimeout).toBe(0);
943
+ });
944
+
909
945
  test('should start application with config initialization', async () => {
910
946
  @Module({})
911
947
  class TestModule {}
@@ -27,16 +27,23 @@ import { makeTraceService, TraceService } from '@onebun/trace';
27
27
 
28
28
  import {
29
29
  getControllerMetadata,
30
+ getControllerMiddleware,
30
31
  getSseMetadata,
31
32
  type SseDecoratorOptions,
32
33
  } from '../decorators/decorators';
34
+ import { OneBunFile, validateFile } from '../file/onebun-file';
33
35
  import {
34
36
  NotInitializedConfig,
35
37
  type IConfig,
36
38
  type OneBunAppConfig,
37
39
  } from '../module/config.interface';
38
40
  import { ConfigServiceImpl } from '../module/config.service';
39
- import { createSseStream } from '../module/controller';
41
+ import {
42
+ createSseStream,
43
+ DEFAULT_IDLE_TIMEOUT,
44
+ DEFAULT_SSE_HEARTBEAT_MS,
45
+ DEFAULT_SSE_TIMEOUT,
46
+ } from '../module/controller';
40
47
  import { OneBunModule } from '../module/module';
41
48
  import { QueueService, type QueueAdapter } from '../queue';
42
49
  import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
@@ -484,7 +491,7 @@ export class OneBunApplication {
484
491
 
485
492
  /**
486
493
  * Create a route handler with the full OneBun request lifecycle:
487
- * tracing setup → middleware chain → executeHandler → metrics → tracing end
494
+ * tracing setup → per-request timeout → middleware chain → executeHandler → metrics → tracing end
488
495
  */
489
496
  function createRouteHandler(
490
497
  routeMeta: RouteMetadata,
@@ -492,10 +499,28 @@ export class OneBunApplication {
492
499
  controller: Controller,
493
500
  fullPath: string,
494
501
  method: string,
495
- ): (req: OneBunRequest) => Promise<Response> {
496
- return async (req) => {
502
+ ): (req: OneBunRequest, server: ReturnType<typeof Bun.serve>) => Promise<Response> {
503
+ // Determine the effective timeout for this route:
504
+ // SSE endpoints check @Sse({ timeout }) first, then route-level, then DEFAULT_SSE_TIMEOUT
505
+ // Normal endpoints use route-level timeout only (undefined = use global idleTimeout)
506
+ const isSse = routeMeta.handler
507
+ ? getSseMetadata(Object.getPrototypeOf(controller), routeMeta.handler) !== undefined
508
+ : false;
509
+ const sseDecoratorOptions = routeMeta.handler
510
+ ? getSseMetadata(Object.getPrototypeOf(controller), routeMeta.handler)
511
+ : undefined;
512
+ const effectiveTimeout: number | undefined = isSse
513
+ ? (sseDecoratorOptions?.timeout ?? routeMeta.timeout ?? DEFAULT_SSE_TIMEOUT)
514
+ : routeMeta.timeout;
515
+
516
+ return async (req, server) => {
497
517
  const startTime = Date.now();
498
518
 
519
+ // Apply per-request idle timeout if configured
520
+ if (effectiveTimeout !== undefined) {
521
+ server.timeout(req, effectiveTimeout);
522
+ }
523
+
499
524
  // Setup tracing context if available and enabled
500
525
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
501
526
  let traceSpan: any = null;
@@ -700,6 +725,12 @@ export class OneBunApplication {
700
725
  };
701
726
  }
702
727
 
728
+ // Application-wide middleware — resolve class constructors via root module DI
729
+ const globalMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
730
+ const globalMiddleware: Function[] = globalMiddlewareClasses.length > 0
731
+ ? (this.ensureModule().resolveMiddleware?.(globalMiddlewareClasses) ?? [])
732
+ : [];
733
+
703
734
  // Add routes from controllers
704
735
  for (const controllerClass of controllers) {
705
736
  const controllerMetadata = getControllerMetadata(controllerClass);
@@ -724,6 +755,15 @@ export class OneBunApplication {
724
755
 
725
756
  const controllerPath = controllerMetadata.path;
726
757
 
758
+ // Module-level middleware (already resolved bound functions)
759
+ const moduleMiddleware = this.ensureModule().getModuleMiddleware?.(controllerClass) ?? [];
760
+
761
+ // Controller-level middleware — resolve class constructors via root module DI
762
+ const ctrlMiddlewareClasses = getControllerMiddleware(controllerClass);
763
+ const ctrlMiddleware: Function[] = ctrlMiddlewareClasses.length > 0
764
+ ? (this.ensureModule().resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
765
+ : [];
766
+
727
767
  for (const route of controllerMetadata.routes) {
728
768
  // Combine: appPrefix + controllerPath + routePath
729
769
  // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
@@ -733,8 +773,26 @@ export class OneBunApplication {
733
773
  controller,
734
774
  );
735
775
 
776
+ // Route-level middleware — resolve class constructors via root module DI
777
+ const routeMiddlewareClasses = route.middleware ?? [];
778
+ const routeMiddleware: Function[] = routeMiddlewareClasses.length > 0
779
+ ? (this.ensureModule().resolveMiddleware?.(routeMiddlewareClasses) ?? [])
780
+ : [];
781
+
782
+ // Merge middleware: global → module → controller → route
783
+ const mergedMiddleware = [
784
+ ...globalMiddleware,
785
+ ...moduleMiddleware,
786
+ ...ctrlMiddleware,
787
+ ...routeMiddleware,
788
+ ];
789
+ const routeWithMergedMiddleware: RouteMetadata = {
790
+ ...route,
791
+ middleware: mergedMiddleware.length > 0 ? mergedMiddleware : undefined,
792
+ };
793
+
736
794
  // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
737
- const wrappedHandler = createRouteHandler(route, handler, controller, fullPath, method);
795
+ const wrappedHandler = createRouteHandler(routeWithMergedMiddleware, handler, controller, fullPath, method);
738
796
 
739
797
  // Add to bunRoutes grouped by path and method
740
798
  if (!bunRoutes[fullPath]) {
@@ -843,6 +901,8 @@ export class OneBunApplication {
843
901
  this.server = Bun.serve<WsClientData>({
844
902
  port: this.options.port,
845
903
  hostname: this.options.host,
904
+ // Idle timeout (seconds) — default 120s to support SSE and long-running requests
905
+ idleTimeout: this.options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
846
906
  // WebSocket handlers
847
907
  websocket: wsHandlers,
848
908
  // Bun routes API: all endpoints are handled here
@@ -911,6 +971,43 @@ export class OneBunApplication {
911
971
  throw error;
912
972
  }
913
973
 
974
+ /**
975
+ * Extract an OneBunFile from a JSON value.
976
+ * Supports two formats:
977
+ * - String: raw base64 data
978
+ * - Object: { data: string, filename?: string, mimeType?: string }
979
+ */
980
+ function extractFileFromJsonValue(value: unknown): OneBunFile | undefined {
981
+ if (typeof value === 'string' && value.length > 0) {
982
+ return OneBunFile.fromBase64(value);
983
+ }
984
+
985
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
986
+ const obj = value as Record<string, unknown>;
987
+ if (typeof obj.data === 'string' && obj.data.length > 0) {
988
+ return OneBunFile.fromBase64(
989
+ obj.data,
990
+ typeof obj.filename === 'string' ? obj.filename : undefined,
991
+ typeof obj.mimeType === 'string' ? obj.mimeType : undefined,
992
+ );
993
+ }
994
+ }
995
+
996
+ return undefined;
997
+ }
998
+
999
+ /**
1000
+ * Extract a file from a JSON body by field name
1001
+ */
1002
+ function extractFileFromJson(
1003
+ jsonBody: Record<string, unknown>,
1004
+ fieldName: string,
1005
+ ): OneBunFile | undefined {
1006
+ const fieldValue = jsonBody[fieldName];
1007
+
1008
+ return extractFileFromJsonValue(fieldValue);
1009
+ }
1010
+
914
1011
  /**
915
1012
  * Execute route handler with parameter injection and validation.
916
1013
  * Path parameters come from BunRequest.params (populated by Bun routes API).
@@ -951,6 +1048,52 @@ export class OneBunApplication {
951
1048
  // Sort params by index to ensure correct order
952
1049
  const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
953
1050
 
1051
+ // Pre-parse body for file upload params (FormData or JSON, cached for all params)
1052
+ const needsFileData = sortedParams.some(
1053
+ (p) =>
1054
+ p.type === ParamType.FILE ||
1055
+ p.type === ParamType.FILES ||
1056
+ p.type === ParamType.FORM_FIELD,
1057
+ );
1058
+
1059
+ // Validate that @Body and file decorators are not used on the same method
1060
+ if (needsFileData) {
1061
+ const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1062
+ if (hasBody) {
1063
+ throw new Error(
1064
+ 'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
1065
+ 'Both consume the request body. Use file decorators for multipart/base64 uploads.',
1066
+ );
1067
+ }
1068
+ }
1069
+
1070
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1071
+ let formData: any = null;
1072
+ let jsonBody: Record<string, unknown> | null = null;
1073
+ let isMultipart = false;
1074
+
1075
+ if (needsFileData) {
1076
+ const contentType = req.headers.get('content-type') || '';
1077
+
1078
+ if (contentType.includes('multipart/form-data')) {
1079
+ isMultipart = true;
1080
+ try {
1081
+ formData = await req.formData();
1082
+ } catch {
1083
+ formData = null;
1084
+ }
1085
+ } else if (contentType.includes('application/json')) {
1086
+ try {
1087
+ const parsed = await req.json();
1088
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1089
+ jsonBody = parsed as Record<string, unknown>;
1090
+ }
1091
+ } catch {
1092
+ jsonBody = null;
1093
+ }
1094
+ }
1095
+ }
1096
+
954
1097
  for (const param of sortedParams) {
955
1098
  switch (param.type) {
956
1099
  case ParamType.PATH:
@@ -989,6 +1132,100 @@ export class OneBunApplication {
989
1132
  args[param.index] = undefined;
990
1133
  break;
991
1134
 
1135
+ case ParamType.FILE: {
1136
+ let file: OneBunFile | undefined;
1137
+
1138
+ if (isMultipart && formData && param.name) {
1139
+ const entry = formData.get(param.name);
1140
+ if (entry instanceof File) {
1141
+ file = new OneBunFile(entry);
1142
+ }
1143
+ } else if (jsonBody && param.name) {
1144
+ file = extractFileFromJson(jsonBody, param.name);
1145
+ }
1146
+
1147
+ if (file && param.fileOptions) {
1148
+ validateFile(file, param.fileOptions, param.name);
1149
+ }
1150
+
1151
+ args[param.index] = file;
1152
+ break;
1153
+ }
1154
+
1155
+ case ParamType.FILES: {
1156
+ let files: OneBunFile[] = [];
1157
+
1158
+ if (isMultipart && formData) {
1159
+ if (param.name) {
1160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1161
+ const entries: any[] = formData.getAll(param.name);
1162
+ files = entries
1163
+ .filter((entry: unknown): entry is File => entry instanceof File)
1164
+ .map((f: File) => new OneBunFile(f));
1165
+ } else {
1166
+ // Get all files from all fields
1167
+ for (const [, value] of formData.entries()) {
1168
+ if (value instanceof File) {
1169
+ files.push(new OneBunFile(value));
1170
+ }
1171
+ }
1172
+ }
1173
+ } else if (jsonBody) {
1174
+ if (param.name) {
1175
+ const fieldValue = jsonBody[param.name];
1176
+ if (Array.isArray(fieldValue)) {
1177
+ files = fieldValue
1178
+ .map((item) => extractFileFromJsonValue(item))
1179
+ .filter((f): f is OneBunFile => f !== undefined);
1180
+ }
1181
+ } else {
1182
+ // Extract all file-like values from JSON
1183
+ for (const [, value] of Object.entries(jsonBody)) {
1184
+ const file = extractFileFromJsonValue(value);
1185
+ if (file) {
1186
+ files.push(file);
1187
+ }
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ // Validate maxCount
1193
+ if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1194
+ throw new Error(
1195
+ `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1196
+ );
1197
+ }
1198
+
1199
+ // Validate each file
1200
+ if (param.fileOptions) {
1201
+ for (const file of files) {
1202
+ validateFile(file, param.fileOptions, param.name);
1203
+ }
1204
+ }
1205
+
1206
+ args[param.index] = files;
1207
+ break;
1208
+ }
1209
+
1210
+ case ParamType.FORM_FIELD: {
1211
+ let value: string | undefined;
1212
+
1213
+ if (isMultipart && formData && param.name) {
1214
+ const entry = formData.get(param.name);
1215
+ if (typeof entry === 'string') {
1216
+ value = entry;
1217
+ }
1218
+ } else if (jsonBody && param.name) {
1219
+ const jsonValue = jsonBody[param.name];
1220
+ if (jsonValue !== undefined && jsonValue !== null) {
1221
+ value = String(jsonValue);
1222
+ }
1223
+ }
1224
+
1225
+ args[param.index] = value;
1226
+ break;
1227
+ }
1228
+
992
1229
  default:
993
1230
  args[param.index] = undefined;
994
1231
  }
@@ -998,6 +1235,16 @@ export class OneBunApplication {
998
1235
  throw new Error(`Required parameter ${param.name || param.index} is missing`);
999
1236
  }
1000
1237
 
1238
+ // For FILES type, also check for empty array when required
1239
+ if (
1240
+ param.isRequired &&
1241
+ param.type === ParamType.FILES &&
1242
+ Array.isArray(args[param.index]) &&
1243
+ (args[param.index] as unknown[]).length === 0
1244
+ ) {
1245
+ throw new Error(`Required parameter ${param.name || param.index} is missing`);
1246
+ }
1247
+
1001
1248
  // Apply arktype schema validation if provided
1002
1249
  if (param.schema && args[param.index] !== undefined) {
1003
1250
  try {
@@ -1170,9 +1417,14 @@ export class OneBunApplication {
1170
1417
 
1171
1418
  // Check if result is an async iterable (generator)
1172
1419
  if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
1420
+ // Apply default heartbeat if none specified to keep the connection alive
1421
+ const effectiveOptions = {
1422
+ ...options,
1423
+ heartbeat: options.heartbeat ?? DEFAULT_SSE_HEARTBEAT_MS,
1424
+ };
1173
1425
  const stream = createSseStream(
1174
1426
  result as AsyncIterable<unknown>,
1175
- options,
1427
+ effectiveOptions,
1176
1428
  );
1177
1429
 
1178
1430
  return new Response(stream, {
@@ -143,6 +143,7 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
143
143
  envOverrides: { ...appOptions.envOverrides, ...serviceOptions.envOverrides },
144
144
  envSchemaExtend: serviceOptions.envSchemaExtend,
145
145
  logger: { ...appOptions.logger, ...serviceOptions.logger },
146
+ middleware: serviceOptions.middleware ?? appOptions.middleware,
146
147
  metrics: { ...appOptions.metrics, ...serviceOptions.metrics },
147
148
  tracing: { ...appOptions.tracing, ...serviceOptions.tracing },
148
149
  };
@@ -197,6 +198,7 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
197
198
  basePath: mergedOptions.basePath,
198
199
  // When routePrefix is true, use service name as prefix
199
200
  routePrefix: mergedOptions.routePrefix ? name : undefined,
201
+ middleware: mergedOptions.middleware,
200
202
  envSchema: mergedEnvSchema,
201
203
  envOptions: {
202
204
  ...this.options.envOptions,
@@ -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'> {
33
+ extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware'> {
34
34
  /**
35
35
  * Add service name as prefix to all routes.
36
36
  * When true, the service name will be used as routePrefix.