Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dispose hooks (deprecate useContainerRaw) #323

Merged
merged 3 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ dist
.yalc

yalc.lock

*.svg
1,484 changes: 0 additions & 1,484 deletions dependency-graph.svg

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"lint": "eslint src/**/*.ts",
"format": "eslint src/**/*.ts --fix",
"build:dev": "tsup --metafile",
"build:prod": "tsup --minify",
jacoobes marked this conversation as resolved.
Show resolved Hide resolved
"build:prod": "tsup ",
"prepare": "npm run build:prod",
"pretty": "prettier --write .",
"tdd": "vitest",
Expand Down
1 change: 1 addition & 0 deletions src/core/_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type { VoidResult } from '../types/core-plugin';
export { SernError } from './structures/enums';
export { ModuleStore } from './structures/module-store';
export * as DefaultServices from './structures/services';
export { useContainerRaw } from './ioc/base'
9 changes: 9 additions & 0 deletions src/core/contracts/disposable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Awaitable } from '../../types/utility';

/**
* Represents a Disposable contract.
* Let dependencies implement this to dispose and cleanup.
*/
export interface Disposable {
dispose(): Awaitable<unknown>;
}
1 change: 1 addition & 0 deletions src/core/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './module-manager';
export * from './module-store';
export * from './init';
export * from './emitter';
export * from './disposable'
2 changes: 2 additions & 0 deletions src/core/ioc/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { CoreContainer } from './container';
let containerSubject: CoreContainer<Partial<Dependencies>>;

/**
* @deprecated
* Returns the underlying data structure holding all dependencies.
* Exposes methods from iti
* Use the Service API. The container should be readonly
*/
export function useContainerRaw() {
assert.ok(
Expand Down
59 changes: 26 additions & 33 deletions src/core/ioc/container.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { Container } from 'iti';
import { SernEmitter } from '../';
import { isAsyncFunction } from 'node:util/types';

import { Disposable, SernEmitter } from '../';
import * as assert from 'node:assert';
import { Subject } from 'rxjs';
import { DefaultServices, ModuleStore } from '../_internal';
import * as Hooks from './hooks'


/**
* Provides all the defaults for sern to function properly.
* The only user provided dependency needs to be @sern/client
* A semi-generic container that provides error handling, emitter, and module store.
* For the handler to operate correctly, The only user provided dependency needs to be @sern/client
*/
export class CoreContainer<T extends Partial<Dependencies>> extends Container<T, {}> {
private ready$ = new Subject<never>();
private beenCalled = new Set<PropertyKey>();
private ready$ = new Subject<void>();
constructor() {
super();
assert.ok(!this.isReady(), 'Listening for dispose & init should occur prior to sern being ready.');

this.listenForInsertions();
const { unsubscribe } = Hooks.createInitListener(this);
this.ready$
.subscribe({ complete: unsubscribe });

(this as Container<{}, {}>)
.add({
Expand All @@ -32,36 +34,27 @@ export class CoreContainer<T extends Partial<Dependencies>> extends Container<T,
});
}

private listenForInsertions() {
assert.ok(
!this.isReady(),
'listening for init functions should only occur prior to sern being ready.',
);
const unsubscriber = this.on('containerUpserted', e => this.callInitHooks(e));

this.ready$.subscribe({
complete: unsubscriber,
});
}

private async callInitHooks(e: { key: keyof T; newContainer: T[keyof T] | null }) {
const dep = e.newContainer;

assert.ok(dep);
//Ignore any dependencies that are not objects or array
if (typeof dep !== 'object' || Array.isArray(dep)) {
return;
}
if ('init' in dep && typeof dep.init === 'function' && !this.beenCalled.has(e.key)) {
isAsyncFunction(dep.init) ? await dep.init() : dep.init();
this.beenCalled.add(e.key);
}
}

isReady() {

return this.ready$.closed;
}
override async disposeAll() {

const otherDisposables = Object
.entries(this._context)
.flatMap(([key, value]) =>
'dispose' in value
? [key]
: []);

for(const key of otherDisposables) {
this.addDisposer({ [key]: (dep: Disposable) => dep.dispose() } as never);
}
await super.disposeAll()
}
ready() {
this.ready$.complete();
this.ready$.unsubscribe();
}
}
2 changes: 1 addition & 1 deletion src/core/ioc/dependency-injection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
import { SernError, DefaultServices } from '../_internal';
import { DefaultServices } from '../_internal';
import { useContainerRaw } from './base';
import { CoreContainer } from './container';

Expand Down
40 changes: 40 additions & 0 deletions src/core/ioc/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CoreContainer } from "./container"

interface HookEvent {
key : PropertyKey
newContainer: any
}
type HookName = 'init';

export const createInitListener = (coreContainer : CoreContainer<any>) => {
const initCalled = new Set<PropertyKey>();
const hasCallableMethod = createPredicate(initCalled);
const unsubscribe = coreContainer.on('containerUpserted', async (event) => {

if(isNotHookable(event)) {
return;
}

if(hasCallableMethod('init', event)) {
await event.newContainer?.init();
initCalled.add(event.key);
}

});
return { unsubscribe };
}

const isNotHookable = (hk: HookEvent) => {
return typeof hk.newContainer !== 'object'
|| Array.isArray(hk.newContainer)
|| hk.newContainer === null;
}

const createPredicate = <T extends HookEvent>(called: Set<PropertyKey>) => {
return (hookName: HookName, event: T) => {
const hasMethod = Reflect.has(event.newContainer!, hookName);
const beenCalledOnce = !called.has(event.key)

return hasMethod && beenCalledOnce
}
}
2 changes: 1 addition & 1 deletion src/core/ioc/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { useContainerRaw, makeDependencies } from './base';
export { makeDependencies } from './base';
export { Service, Services, single, transient } from './dependency-injection';
5 changes: 4 additions & 1 deletion src/core/structures/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,8 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
}

function safeUnwrap<T>(res: Result<T, T>) {
return res.unwrap()
if(res.isOk()) {
return res.expect("Tried unwrapping message field: " + res)
}
return res.expectErr("Tried unwrapping interaction field" + res)
}
3 changes: 2 additions & 1 deletion src/handlers/event-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {
handleError,
SernError,
VoidResult,
useContainerRaw,
} from '../core/_internal';
import { Emitter, ErrorHandling, Logging, ModuleManager, useContainerRaw } from '../core';
import { Emitter, ErrorHandling, Logging, ModuleManager } from '../core';
import { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
import { ObservableInput, pipe } from 'rxjs';
import { SernEmitter } from '../core';
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@ export {
CommandExecutable,
} from './core/modules';

export {
useContainerRaw
} from './core/_internal'
export { controller } from './sern';
54 changes: 44 additions & 10 deletions test/core/ioc.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreContainer } from '../../src/core/ioc/container';
import { CoreDependencies } from '../../src/core/ioc';
import { EventEmitter } from 'events';
import { DefaultLogging, Init, Logging } from '../../src/core';
import { DefaultLogging, Disposable, Init, Logging } from '../../src/core';
import { CoreDependencies } from '../../src/types/ioc';

describe('ioc container', () => {
let container: CoreContainer<{}>;
let initDependency: Logging & Init;
let container: CoreContainer<{}> = new CoreContainer();
let dependency: Logging & Init & Disposable;
beforeEach(() => {
initDependency = {
dependency = {
init: vi.fn(),
error(): void {},
warning(): void {},
info(): void {},
debug(): void {},
dispose: vi.fn()
};
container = new CoreContainer();
});

const wait = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds));
class DB implements Init, Disposable {
public connected = false
constructor() {}
async init() {
this.connected = true
await wait(10)
}
async dispose() {
await wait(20)
this.connected = false
}
}
it('should be ready after calling container.ready()', () => {
container.ready();
expect(container.isReady()).toBe(true);
Expand All @@ -39,14 +52,35 @@ describe('ioc container', () => {
}
});
it('should init modules', () => {
container.upsert({ '@sern/logger': initDependency });
container.upsert({ '@sern/logger': dependency });
container.ready();
expect(dependency.init).to.toHaveBeenCalledOnce();
});
it('should dispose modules', async () => {

container.upsert({ '@sern/logger': dependency })

container.ready();
expect(initDependency.init).to.toHaveBeenCalledOnce();
// We need to access the dependency at least once to be able to dispose of it.
container.get('@sern/logger' as never);
await container.disposeAll();
expect(dependency.dispose).toHaveBeenCalledOnce();
});

it('should init and dispose', async () => {
container.add({ db: new DB() })
container.ready()
const db = container.get('db' as never) as DB
expect(db.connected).toBeTruthy()

await container.disposeAll();

expect(db.connected).toBeFalsy()
})

it('should not lazy module', () => {
container.upsert({ '@sern/logger': () => initDependency });
container.upsert({ '@sern/logger': () => dependency });
container.ready();
expect(initDependency.init).toHaveBeenCalledTimes(0);
expect(dependency.init).toHaveBeenCalledTimes(0);
});
});
4 changes: 2 additions & 2 deletions test/core/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ describe('services', () => {
.map((path, i) => `${path}/${modules[i]}.js`);

const metadata: CommandMeta[] = modules.map((cm, i) => ({
id: Id.create(cm.name, cm.type),
id: Id.create(cm.name!, cm.type),
isClass: false,
fullPath: `${paths[i]}/${cm.name}.js`,
}));
const moduleManager = container.get('@sern/modules');
let i = 0;
for (const m of modules) {
moduleManager.set(Id.create(m.name, m.type), paths[i]);
moduleManager.set(Id.create(m.name!, m.type), paths[i]);
moduleManager.setMetadata(m, metadata[i]);
i++;
}
Expand Down
31 changes: 3 additions & 28 deletions tsup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const shared = {
external: ['discord.js', 'iti'],
platform: 'node',
clean: true,
sourcemap: false,
sourcemap: true,
jacoobes marked this conversation as resolved.
Show resolved Hide resolved
treeshake: {
moduleSideEffects: false,
correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import
Expand All @@ -17,33 +17,8 @@ export default defineConfig([
target: 'node18',
tsconfig: './tsconfig.json',
outDir: './dist',
splitting: true,
minify: false,
dts: true,
...shared,
},
// {
// format: 'cjs',
// esbuildPlugins: [ifdefPlugin({ variables: { MODE: 'cjs' }, verbose: true })],
// splitting: false,
// target: 'node18',
// tsconfig: './tsconfig-cjs.json',
// outDir: './dist/cjs',
// outExtension() {
// return {
// js: '.cjs',
// };
// },
// async onSuccess() {
// console.log('writing json commonjs');
// await writeFile('./dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }));
// },
// ...shared,
// },
// {
// dts: {
// only: true,
// },
// entry: ['src/index.ts'],
// outDir: 'dist',
// },
]);
]);