feat: dispose hooks (deprecate useContainerRaw) (#323)

* feat: dispose hooks

* build: unminify, add source map, deprecate useContainerRaw

* fix regression of context and fix tsup build
This commit is contained in:
Jacob Nguyen
2023-08-17 12:51:24 -05:00
committed by GitHub
parent 4b97d86908
commit 26ccd118ff
17 changed files with 142 additions and 1562 deletions

2
.gitignore vendored
View File

@@ -95,3 +95,5 @@ dist
.yalc
yalc.lock
*.svg

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -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",

View File

@@ -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'

View File

@@ -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>;
}

View File

@@ -4,3 +4,4 @@ export * from './module-manager';
export * from './module-store';
export * from './init';
export * from './emitter';
export * from './disposable'

View File

@@ -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(

View File

@@ -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({
@@ -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();
}
}

View File

@@ -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';

40
src/core/ioc/hooks.ts Normal file
View File

@@ -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
}
}

View File

@@ -1,2 +1,2 @@
export { useContainerRaw, makeDependencies } from './base';
export { makeDependencies } from './base';
export { Service, Services, single, transient } from './dependency-injection';

View File

@@ -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)
}

View File

@@ -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';

View File

@@ -50,4 +50,7 @@ export {
CommandExecutable,
} from './core/modules';
export {
useContainerRaw
} from './core/_internal'
export { controller } from './sern';

View File

@@ -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);
@@ -39,14 +52,35 @@ describe('ioc container', () => {
}
});
it('should init modules', () => {
container.upsert({ '@sern/logger': initDependency });
container.upsert({ '@sern/logger': dependency });
container.ready();
expect(initDependency.init).to.toHaveBeenCalledOnce();
expect(dependency.init).to.toHaveBeenCalledOnce();
});
it('should dispose modules', async () => {
container.upsert({ '@sern/logger': dependency })
container.ready();
// 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);
});
});

View File

@@ -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++;
}

View File

@@ -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
@@ -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',
// },
]);