Skip to content

Commit

Permalink
feat: dispose hooks (deprecate useContainerRaw) (#323)
Browse files Browse the repository at this point in the history
* feat: dispose hooks

* build: unminify, add source map, deprecate useContainerRaw

* fix regression of context and fix tsup build
  • Loading branch information
jacoobes authored Aug 17, 2023
1 parent 4b97d86 commit 26ccd11
Show file tree
Hide file tree
Showing 17 changed files with 142 additions and 1,562 deletions.
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",
"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,
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',
// },
]);
]);

0 comments on commit 26ccd11

Please sign in to comment.