@onebun/core 0.2.11 → 0.2.13

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.
@@ -286,6 +286,205 @@ export function getConstructorParamTypes(target: Function): Function[] | undefin
286
286
  return undefined;
287
287
  }
288
288
 
289
+ /**
290
+ * Diagnostic: check whether Bun is emitting decorator metadata (design:paramtypes).
291
+ *
292
+ * Scans an array of decorated classes. If at least one class has constructor
293
+ * parameters (target.length > 0) but NONE of them have design:paramtypes,
294
+ * then emitDecoratorMetadata is not working — typically because the setting
295
+ * is missing from the root tsconfig.json that Bun actually reads.
296
+ *
297
+ * @param decoratedClasses - Classes registered via @Service / @Controller / @Middleware
298
+ * @returns Object with diagnostic result and details
299
+ */
300
+ export function diagnoseDecoratorMetadata(decoratedClasses: Function[]): {
301
+ ok: boolean;
302
+ classesWithParams: number;
303
+ classesWithMetadata: number;
304
+ } {
305
+ let classesWithParams = 0;
306
+ let classesWithMetadata = 0;
307
+
308
+ for (const cls of decoratedClasses) {
309
+ if (cls.length > 0) {
310
+ classesWithParams++;
311
+ const types = (globalThis as any).Reflect?.getMetadata?.('design:paramtypes', cls);
312
+ if (types && Array.isArray(types) && types.length > 0) {
313
+ classesWithMetadata++;
314
+ }
315
+ }
316
+ }
317
+
318
+ return {
319
+ ok: classesWithParams === 0 || classesWithMetadata > 0,
320
+ classesWithParams,
321
+ classesWithMetadata,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Build a detailed diagnostic message by inspecting the project's tsconfig files.
327
+ *
328
+ * Walks up from `process.cwd()` looking for tsconfig.json files, reads them,
329
+ * and tells the user exactly which file to edit and what to add.
330
+ */
331
+ export function buildDecoratorMetadataDiagnosticMessage(
332
+ classesWithParams: number,
333
+ ): string {
334
+ const fs = require('node:fs');
335
+ const pathModule = require('node:path');
336
+
337
+ const header =
338
+ `[OneBun] Dependency injection is broken: none of the ${classesWithParams} ` +
339
+ 'service(s) with constructor parameters have design:paramtypes metadata.\n' +
340
+ 'Bun is NOT emitting decorator metadata.\n';
341
+
342
+ type TsconfigInfo = { path: string; content: any; hasEmit: boolean; hasExperimental: boolean };
343
+
344
+ const readTsconfig = (tsconfigPath: string): TsconfigInfo | null => {
345
+ try {
346
+ if (!fs.existsSync(tsconfigPath)) {
347
+ return null;
348
+ }
349
+ const raw = fs.readFileSync(tsconfigPath, 'utf-8');
350
+ // Strip comments (single-line // and multi-line /* */) for JSON.parse
351
+ const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
352
+ const parsed = JSON.parse(stripped);
353
+ const compilerOptions = parsed.compilerOptions || {};
354
+
355
+ return {
356
+ path: tsconfigPath,
357
+ content: parsed,
358
+ hasEmit: compilerOptions.emitDecoratorMetadata === true,
359
+ hasExperimental: compilerOptions.experimentalDecorators === true,
360
+ };
361
+ } catch {
362
+ return null;
363
+ }
364
+ };
365
+
366
+ // 1. Find tsconfig.json files walking UP from cwd to filesystem root
367
+ const tsconfigFiles: TsconfigInfo[] = [];
368
+ let dir = process.cwd();
369
+ const root = pathModule.parse(dir).root;
370
+
371
+ while (dir !== root) {
372
+ const info = readTsconfig(pathModule.join(dir, 'tsconfig.json'));
373
+ if (info) {
374
+ tsconfigFiles.push(info);
375
+ }
376
+ dir = pathModule.dirname(dir);
377
+ }
378
+
379
+ // 2. Also scan immediate subdirectories of cwd for child tsconfig.json files
380
+ // (e.g. packages/backend/tsconfig.json in a monorepo)
381
+ const childTsconfigFiles: TsconfigInfo[] = [];
382
+ try {
383
+ const scanDirs = [process.cwd()];
384
+ // Scan up to 2 levels deep to find packages/*/tsconfig.json and similar
385
+ for (const scanDir of scanDirs) {
386
+ const entries = fs.readdirSync(scanDir, { withFileTypes: true });
387
+ for (const entry of entries) {
388
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
389
+ const subdir = pathModule.join(scanDir, entry.name);
390
+ const info = readTsconfig(pathModule.join(subdir, 'tsconfig.json'));
391
+ if (info) {
392
+ childTsconfigFiles.push(info);
393
+ }
394
+ // One level deeper (e.g. packages/backend/)
395
+ try {
396
+ const subEntries = fs.readdirSync(subdir, { withFileTypes: true });
397
+ for (const subEntry of subEntries) {
398
+ if (subEntry.isDirectory() && !subEntry.name.startsWith('.') && subEntry.name !== 'node_modules') {
399
+ const info2 = readTsconfig(pathModule.join(subdir, subEntry.name, 'tsconfig.json'));
400
+ if (info2) {
401
+ childTsconfigFiles.push(info2);
402
+ }
403
+ }
404
+ }
405
+ } catch {
406
+ // Skip unreadable directories
407
+ }
408
+ }
409
+ }
410
+ }
411
+ } catch {
412
+ // Skip if directory scanning fails
413
+ }
414
+
415
+ if (tsconfigFiles.length === 0) {
416
+ return (
417
+ header +
418
+ '\nNo tsconfig.json found. Create one in your project root with:\n\n' +
419
+ ' {\n' +
420
+ ' "compilerOptions": {\n' +
421
+ ' "experimentalDecorators": true,\n' +
422
+ ' "emitDecoratorMetadata": true\n' +
423
+ ' }\n' +
424
+ ' }\n'
425
+ );
426
+ }
427
+
428
+ // The first (deepest / closest to cwd) tsconfig is what the user likely expects to work.
429
+ // The last (shallowest / closest to root) is what Bun actually reads.
430
+ const rootTsconfig = tsconfigFiles[tsconfigFiles.length - 1];
431
+
432
+ // Check if any child tsconfig has the settings but root does not
433
+ // Look in both parent chain and scanned subdirectories
434
+ const allConfigs = [...tsconfigFiles, ...childTsconfigFiles];
435
+ const childWithSettings = allConfigs.find(
436
+ (t) => t.path !== rootTsconfig.path && (t.hasEmit || t.hasExperimental),
437
+ );
438
+
439
+ const lines: string[] = [header];
440
+
441
+ if (rootTsconfig.hasEmit && rootTsconfig.hasExperimental) {
442
+ // Root has both settings — unusual, might be a different issue
443
+ lines.push(`\nRoot tsconfig (${rootTsconfig.path}) already has both settings.`);
444
+ lines.push('If DI is still broken, check that Bun is reading this file for your entry point.');
445
+
446
+ return lines.join('\n');
447
+ }
448
+
449
+ // Report what's missing from the root tsconfig
450
+ const missing: string[] = [];
451
+ if (!rootTsconfig.hasExperimental) {
452
+ missing.push('"experimentalDecorators": true');
453
+ }
454
+ if (!rootTsconfig.hasEmit) {
455
+ missing.push('"emitDecoratorMetadata": true');
456
+ }
457
+
458
+ lines.push(`\nRoot tsconfig: ${rootTsconfig.path}`);
459
+ lines.push(`Missing in "compilerOptions": ${missing.join(', ')}`);
460
+
461
+ if (childWithSettings) {
462
+ const childHas: string[] = [];
463
+ if (childWithSettings.hasExperimental) {
464
+ childHas.push('"experimentalDecorators": true');
465
+ }
466
+ if (childWithSettings.hasEmit) {
467
+ childHas.push('"emitDecoratorMetadata": true');
468
+ }
469
+ lines.push(
470
+ `\nNote: ${childWithSettings.path} has ${childHas.join(' and ')},` +
471
+ ' but Bun ignores these when they are only in a child tsconfig.',
472
+ );
473
+ }
474
+
475
+ lines.push(`\nFix: add the missing option(s) to ${rootTsconfig.path}:\n`);
476
+ lines.push(' "compilerOptions": {');
477
+ if (!rootTsconfig.hasExperimental) {
478
+ lines.push(' "experimentalDecorators": true,');
479
+ }
480
+ if (!rootTsconfig.hasEmit) {
481
+ lines.push(' "emitDecoratorMetadata": true');
482
+ }
483
+ lines.push(' }');
484
+
485
+ return lines.join('\n');
486
+ }
487
+
289
488
  /**
290
489
  * Namespace for metadata functions to mimic Reflect API
291
490
  */
@@ -48,6 +48,8 @@ import type {
48
48
  import type { HttpExecutionContext } from './types';
49
49
  import type { ServerWebSocket } from 'bun';
50
50
 
51
+ import { makeMockLoggerLayer } from './testing';
52
+
51
53
  import {
52
54
  Controller,
53
55
  Get,
@@ -109,7 +111,6 @@ import {
109
111
  createWsClient,
110
112
  createNativeWsClient,
111
113
  matchPattern,
112
- makeMockLoggerLayer,
113
114
  hasOnModuleInit,
114
115
  hasOnApplicationInit,
115
116
  hasOnModuleDestroy,
package/src/index.ts CHANGED
@@ -130,8 +130,8 @@ export * from './queue';
130
130
  // Validation
131
131
  export * from './validation';
132
132
 
133
- // Testing Utilities
134
- export * from './testing';
133
+ // Testing Utilities are available via '@onebun/core/testing' subpath import
134
+ // to avoid requiring testcontainers as a mandatory dependency
135
135
 
136
136
  // HTTP Guards
137
137
  export * from './http-guards';
@@ -22,6 +22,7 @@ import {
22
22
  isGlobalModule,
23
23
  registerControllerDependencies,
24
24
  } from '../decorators/decorators';
25
+ import { buildDecoratorMetadataDiagnosticMessage, diagnoseDecoratorMetadata } from '../decorators/metadata';
25
26
  import { QueueService, QueueServiceTag } from '../queue';
26
27
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
27
28
  import { isWebSocketGateway } from '../websocket/ws-decorators';
@@ -70,6 +71,7 @@ const processedGlobalModules: Set<Function> = ((globalThis as any)[Symbol.for('o
70
71
  export function clearGlobalServicesRegistry(): void {
71
72
  globalServicesRegistry.clear();
72
73
  processedGlobalModules.clear();
74
+ OneBunModule.resetDecoratorMetadataDiagnosis();
73
75
  }
74
76
 
75
77
  /**
@@ -274,6 +276,20 @@ export class OneBunModule implements ModuleInstance {
274
276
  * Create services with automatic dependency injection
275
277
  * Services can depend on other services (including imported ones)
276
278
  */
279
+ /**
280
+ * Whether the decorator metadata diagnostic has already run (once per process).
281
+ * Prevents repeated checks across multiple module initializations.
282
+ */
283
+ private static decoratorMetadataDiagnosed = false;
284
+
285
+ /**
286
+ * Reset the diagnostic flag (for testing).
287
+ * @internal
288
+ */
289
+ static resetDecoratorMetadataDiagnosis(): void {
290
+ OneBunModule.decoratorMetadataDiagnosed = false;
291
+ }
292
+
277
293
  private createServicesWithDI(metadata: ReturnType<typeof getModuleMetadata>): void {
278
294
  if (!metadata?.providers) {
279
295
  return;
@@ -295,6 +311,26 @@ export class OneBunModule implements ModuleInstance {
295
311
  }
296
312
  }
297
313
 
314
+ // Run decorator metadata diagnostic once: check that Bun emits design:paramtypes.
315
+ // If emitDecoratorMetadata is missing from the root tsconfig.json, Bun silently
316
+ // skips metadata emission and ALL constructor-based DI breaks.
317
+ // Only mark as diagnosed when we actually found classes with constructor params
318
+ // (modules with only zero-arg services can't tell us anything).
319
+ if (!OneBunModule.decoratorMetadataDiagnosed) {
320
+ const providerClasses = metadata.providers.filter(
321
+ (p): p is Function => typeof p === 'function',
322
+ );
323
+ const diagnosis = diagnoseDecoratorMetadata(providerClasses);
324
+ if (diagnosis.classesWithParams > 0) {
325
+ OneBunModule.decoratorMetadataDiagnosed = true;
326
+ if (!diagnosis.ok) {
327
+ throw new Error(
328
+ buildDecoratorMetadataDiagnosticMessage(diagnosis.classesWithParams),
329
+ );
330
+ }
331
+ }
332
+ }
333
+
298
334
  // Create services in dependency order
299
335
  const pendingProviders = [...metadata.providers.filter((p) => typeof p === 'function')];
300
336
  const createdServices = new Set<string>();
@@ -384,10 +384,6 @@ describe('InMemoryQueueAdapter', () => {
384
384
  expect(adapter.supports('priority')).toBe(true);
385
385
  });
386
386
 
387
- it('should support scheduled-jobs', () => {
388
- expect(adapter.supports('scheduled-jobs')).toBe(true);
389
- });
390
-
391
387
  it('should not support consumer-groups', () => {
392
388
  expect(adapter.supports('consumer-groups')).toBe(false);
393
389
  });
@@ -15,8 +15,6 @@ import type {
15
15
  PublishOptions,
16
16
  SubscribeOptions,
17
17
  Subscription,
18
- ScheduledJobOptions,
19
- ScheduledJobInfo,
20
18
  MessageHandler,
21
19
  } from '../types';
22
20
 
@@ -322,49 +320,6 @@ export class InMemoryQueueAdapter implements QueueAdapter {
322
320
  return subscription;
323
321
  }
324
322
 
325
- // ============================================================================
326
- // Scheduled Jobs
327
- // ============================================================================
328
-
329
- async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
330
- this.ensureConnected();
331
-
332
- if (!this.scheduler) {
333
- throw new Error('Scheduler not initialized');
334
- }
335
-
336
- if (options.schedule.cron) {
337
- this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
338
- metadata: options.metadata,
339
- overlapStrategy: options.overlapStrategy,
340
- });
341
- } else if (options.schedule.every) {
342
- this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
343
- metadata: options.metadata,
344
- });
345
- }
346
- }
347
-
348
- async removeScheduledJob(name: string): Promise<boolean> {
349
- this.ensureConnected();
350
-
351
- if (!this.scheduler) {
352
- return false;
353
- }
354
-
355
- return this.scheduler.removeJob(name);
356
- }
357
-
358
- async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
359
- this.ensureConnected();
360
-
361
- if (!this.scheduler) {
362
- return [];
363
- }
364
-
365
- return this.scheduler.getJobs();
366
- }
367
-
368
323
  // ============================================================================
369
324
  // Features
370
325
  // ============================================================================
@@ -373,7 +328,6 @@ export class InMemoryQueueAdapter implements QueueAdapter {
373
328
  switch (feature) {
374
329
  case 'delayed-messages':
375
330
  case 'priority':
376
- case 'scheduled-jobs':
377
331
  case 'pattern-subscriptions':
378
332
  return true;
379
333
  case 'consumer-groups':
@@ -158,76 +158,12 @@ describe('RedisQueueAdapter', () => {
158
158
  // current RedisClient.raw() implementation. These features need a proper
159
159
  // implementation using Bun's Redis client's sendCommand API.
160
160
 
161
- describe('scheduled jobs', () => {
162
- beforeEach(async () => {
163
- await adapter.connect();
164
- });
165
-
166
- it('should add and get scheduled jobs', async () => {
167
- await adapter.addScheduledJob('test-job', {
168
- pattern: 'job:test',
169
- data: { action: 'process' },
170
- schedule: { every: 1000 },
171
- });
172
-
173
- const jobs = await adapter.getScheduledJobs();
174
-
175
- expect(jobs.find((j) => j.name === 'test-job')).toBeDefined();
176
- });
177
-
178
- it('should add cron scheduled job', async () => {
179
- await adapter.addScheduledJob('cron-job', {
180
- pattern: 'job:cron',
181
- data: { action: 'cron' },
182
- schedule: { cron: '0 * * * *' },
183
- });
184
-
185
- const jobs = await adapter.getScheduledJobs();
186
-
187
- expect(jobs.find((j) => j.name === 'cron-job')).toBeDefined();
188
- });
189
-
190
- it('should remove scheduled job', async () => {
191
- await adapter.addScheduledJob('removable-job', {
192
- pattern: 'job:remove',
193
- data: {},
194
- schedule: { every: 1000 },
195
- });
196
-
197
- const removed = await adapter.removeScheduledJob('removable-job');
198
-
199
- expect(removed).toBe(true);
200
-
201
- const jobs = await adapter.getScheduledJobs();
202
- expect(jobs.find((j) => j.name === 'removable-job')).toBeUndefined();
203
- });
204
-
205
- it('should return false when removing non-existent job', async () => {
206
- const removed = await adapter.removeScheduledJob('non-existent');
207
-
208
- expect(removed).toBe(false);
209
- });
210
-
211
- it('should throw when adding job while disconnected', async () => {
212
- await adapter.disconnect();
213
-
214
- await expect(
215
- adapter.addScheduledJob('fail-job', {
216
- pattern: 'job:fail',
217
- data: {},
218
- schedule: { every: 1000 },
219
- }),
220
- ).rejects.toThrow('not connected');
221
- });
222
- });
223
-
224
161
  describe('features', () => {
225
162
  it('should support all standard queue features', () => {
226
163
  expect(adapter.supports('delayed-messages')).toBe(true);
227
164
  expect(adapter.supports('priority')).toBe(true);
228
165
  expect(adapter.supports('dead-letter-queue')).toBe(true);
229
166
  expect(adapter.supports('retry')).toBe(true);
230
- expect(adapter.supports('scheduled-jobs')).toBe(true);
231
167
  expect(adapter.supports('consumer-groups')).toBe(true);
232
168
  expect(adapter.supports('pattern-subscriptions')).toBe(true);
233
169
  });
@@ -21,8 +21,6 @@ import type {
21
21
  PublishOptions,
22
22
  SubscribeOptions,
23
23
  Subscription,
24
- ScheduledJobOptions,
25
- ScheduledJobInfo,
26
24
  MessageHandler,
27
25
  } from '../types';
28
26
 
@@ -409,45 +407,6 @@ export class RedisQueueAdapter implements QueueAdapter {
409
407
  return subscription;
410
408
  }
411
409
 
412
- // ============================================================================
413
- // Scheduled Jobs
414
- // ============================================================================
415
-
416
- async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
417
- this.ensureConnected();
418
-
419
- if (!this.scheduler) {
420
- throw new Error('Scheduler not initialized');
421
- }
422
-
423
- if (options.schedule.cron) {
424
- this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
425
- metadata: options.metadata,
426
- overlapStrategy: options.overlapStrategy,
427
- });
428
- } else if (options.schedule.every) {
429
- this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
430
- metadata: options.metadata,
431
- });
432
- }
433
- }
434
-
435
- async removeScheduledJob(name: string): Promise<boolean> {
436
- if (!this.scheduler) {
437
- return false;
438
- }
439
-
440
- return this.scheduler.removeJob(name);
441
- }
442
-
443
- async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
444
- if (!this.scheduler) {
445
- return [];
446
- }
447
-
448
- return this.scheduler.getJobs();
449
- }
450
-
451
410
  // ============================================================================
452
411
  // Features
453
412
  // ============================================================================