diff --git a/packages/core/src/index.spec.ts b/packages/core/src/index.spec.ts index 81ce71e..9397480 100644 --- a/packages/core/src/index.spec.ts +++ b/packages/core/src/index.spec.ts @@ -26,6 +26,7 @@ class UserProvider extends BaseService { describe('Instantiation', () => { it('#getSingleton should instantiate a singleton class once', () => { let app = new App(); + app.provideSingleton(Database); let db1 = app.getSingleton(Database); let db2 = app.getSingleton(Database); expect(db1.id).toEqual(db2.id); @@ -33,7 +34,9 @@ describe('Instantiation', () => { it('#getSingleton should instantiate a singleton factory once', () => { let app = new App(); - let factoryFunc = () => app.getSingleton(Database); + let i = 0; + let factoryFunc = (_app: App) => ({ id: ++i }); + app.provideSingleton(factoryFunc); let db1 = app.getSingleton(factoryFunc); let db2 = app.getSingleton(factoryFunc); expect(db1.id).toEqual(db2.id); @@ -42,14 +45,13 @@ describe('Instantiation', () => { it('#load should force a singleton to instantitate', () => { let app = new App(); let dbDidInit: boolean = false; - app.load( - class MyDB extends Database { - constructor(app: App) { - super(app); - dbDidInit = true; - } - }, - ); + class MyDB extends Database { + constructor(app: App) { + super(app); + dbDidInit = true; + } + } + app.load(MyDB); expect(dbDidInit).toBe(true); }); }); @@ -57,6 +59,7 @@ describe('Instantiation', () => { describe('Overrides', () => { it('#getSingleton should use and respect singleton overrides', () => { let app = new App(); + app.provideSingleton(Database); app.overrideSingleton( Database, class MockDb extends Database { @@ -98,6 +101,7 @@ describe('Overrides', () => { it('#clearSingletonOverrides should cause original singletons to instantiate', () => { let app = new App(); + app.provideSingleton(Database); app.overrideSingleton( Database, class MockDb extends Database { @@ -112,6 +116,7 @@ describe('Overrides', () => { describe('App nesting', () => { it('#getSingleton should find instantiated singletons in a parent app', () => { let app = new App(); + app.provideSingleton(Database); let dbId = app.getSingleton(Database).id; let childApp = app.createChildApp(); expect(childApp.getSingleton(Database).id).toEqual(dbId); @@ -119,6 +124,7 @@ describe('App nesting', () => { it('#getSingleton should instantiate non-existing singletons in the child app, not parent', () => { let parentApp = new App(); + parentApp.provideSingleton(Database); let childApp = parentApp.createChildApp(); let childDbId = childApp.getSingleton(Database).id; expect(parentApp.getSingleton(Database).id !== childDbId); @@ -174,3 +180,60 @@ describe('Context disposal', () => { ); }); }); + +describe('providing dependencies', () => { + it('should throw when getting singletons that arent provided', () => { + let app = new App(); + class MySingleton extends AppSingleton {} + expect(() => app.getSingleton(MySingleton)).toThrowError(); + }); + + it('should not throw when getting provided singletons', () => { + let app = new App(); + class MySingleton extends AppSingleton {} + let mySingletonModule = (app: App) => app.provideSingleton(MySingleton); + app.load(mySingletonModule); + app.getSingleton(MySingleton); // shouldn't throw + }); + + it('should throw when you provide singletons twice', () => { + let app = new App(); + class MySingleton extends AppSingleton {} + app.provideSingleton(MySingleton); + expect(() => app.provideSingleton(MySingleton)).toThrow(); + }); + + it('dependencies provided in child apps shouldnt affect parent apps', () => { + let app = new App(); + let childApp = app.createChildApp(); + class MySingleton extends AppSingleton {} + let myPlugin = (app: App) => { + app.provideSingleton(MySingleton); + }; + childApp.load(myPlugin); + expect(() => app.getSingleton(MySingleton)).toThrow(); + }); + + it('dependencies provided in the parent should be present in child apps', () => { + let app = new App(); + class MySingleton extends AppSingleton {} + app.provideSingleton(MySingleton); + let childApp = app.createChildApp(); + childApp.getSingleton(MySingleton); // should not throw + }); + + it('should throw when you require unprovided singletons', () => { + let app = new App(); + class MySingleton extends AppSingleton {} + expect(() => app.requireSingleton(MySingleton)).toThrow(); + }); +}); + +describe('loading plugins', () => { + it('should throw when loading plugins twice', () => { + let app = new App(); + let myPlugin = (app: App) => app.provideSingleton(class MySingleton extends AppSingleton {}); + app.load(myPlugin); + expect(() => app.load(myPlugin)).toThrow(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e86183f..24d09a5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,11 +46,18 @@ export type PublicInterface = { [K in keyof T]: T[K] }; */ export class App { private singletonLocator: Locator; + private providedSingletons: WeakMap, boolean> = new WeakMap(); parentApp: App | null; constructor(opts: { parentApp?: App } = {}) { this.parentApp = opts.parentApp ? opts.parentApp : null; this.singletonLocator = new Locator(this, s => '__appSingleton' in s); + + if (!opts.parentApp) { + // we must provide this, otherwise withServiceContext will fail every time. + // we do it once, on the parent app, because child app construction will fail otherwise. + this.provideSingleton(ServiceContextEvents); // otherwise, withServiceContext fails. + } } /** @@ -102,6 +109,42 @@ export class App { } } + /** + * Registers a singleton as "provided". It's informing the application that a plugin + * agreed to expose that singleton. + * + * Calls to app.getSingleton(UnprovidedService) will fail with an error. + * + * Also see: `App#requireSingleton` + */ + provideSingleton(Klass: ConstructorOrFactory): void { + if (this.isSingletonProvided(Klass)) { + throw new Error(`The singleton ${Klass.name} is already provided.`); + } + this.providedSingletons.set(Klass, true); + } + + private isSingletonProvided(Klass: ConstructorOrFactory): boolean { + if (this.providedSingletons.has(Klass)) { + return true; + } else if (this.parentApp) { + return this.parentApp.isSingletonProvided(Klass); + } else { + return false; + } + } + + /** + * Ensures that the singleton is provided; throws if it's not. + * + * Use this to detect unprovided but used singletons early. + */ + requireSingleton(Klass: ConstructorOrFactory): void { + if (!this.isSingletonProvided(Klass)) { + throw new Error(`The singleton ${Klass} is required, but wasnt provided.`); + } + } + /** * Returns an instance of the singleton, if it exists somewhere here or * in some of the parent apps. If it doesn't it's created in this app. @@ -115,6 +158,12 @@ export class App { if (this.hasSingleton(Klass)) { return this.getExistingSingleton(Klass); } + if (!this.isSingletonProvided(Klass)) { + throw new Error(`Singleton ${Klass.name} wasnt provided`); + // console.warn(`The singleton ${Klass} was constructed, but wasn't provided beforehand.`); + // console.warn(`Please provide it explicitly using "App#provideSingleton(${Klass})".`); + // console.warn('In the future, this will be an error.'); + } return this.singletonLocator.get(Klass); } @@ -170,10 +219,16 @@ export class App { * While singleton classes are typically side effect free and can be instantiated lazily when * first requested, plugins have side-effects, such as adding router routes, adding RPC endpoints * or setting up event listeners. The load method is therefore used to load those plugins. + * + * You can load a plugin only once; load throws an error the second time. */ load(Klass: ConstructorOrFactory): void { - this.getSingleton(Klass); // force initialization; - return; + if (this.isSingletonProvided(Klass)) { + throw new Error(`Singleton ${Klass.name} is already initialized.`); + } else { + this.provideSingleton(Klass); + this.getSingleton(Klass); // force initialization + } } /** @@ -298,6 +353,10 @@ export class ServiceContext { getSingleton(SingletonClass: ConstructorOrFactory): T { return this.app.getSingleton(SingletonClass); } + + requireSingleton(SingletonClass: ConstructorOrFactory) { + return this.app.requireSingleton(SingletonClass); + } } type ContextListener = (serviceCtx: ServiceContext, error: Error | null) => PromiseLike;