Skip to content

Commit 26ccd11

Browse files
authored
feat: dispose hooks (deprecate useContainerRaw) (#323)
* feat: dispose hooks * build: unminify, add source map, deprecate useContainerRaw * fix regression of context and fix tsup build
1 parent 4b97d86 commit 26ccd11

17 files changed

+142
-1562
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,5 @@ dist
9595
.yalc
9696

9797
yalc.lock
98+
99+
*.svg

dependency-graph.svg

-1,484
This file was deleted.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"lint": "eslint src/**/*.ts",
2020
"format": "eslint src/**/*.ts --fix",
2121
"build:dev": "tsup --metafile",
22-
"build:prod": "tsup --minify",
22+
"build:prod": "tsup ",
2323
"prepare": "npm run build:prod",
2424
"pretty": "prettier --write .",
2525
"tdd": "vitest",

src/core/_internal.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export type { VoidResult } from '../types/core-plugin';
77
export { SernError } from './structures/enums';
88
export { ModuleStore } from './structures/module-store';
99
export * as DefaultServices from './structures/services';
10+
export { useContainerRaw } from './ioc/base'

src/core/contracts/disposable.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Awaitable } from '../../types/utility';
2+
3+
/**
4+
* Represents a Disposable contract.
5+
* Let dependencies implement this to dispose and cleanup.
6+
*/
7+
export interface Disposable {
8+
dispose(): Awaitable<unknown>;
9+
}

src/core/contracts/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './module-manager';
44
export * from './module-store';
55
export * from './init';
66
export * from './emitter';
7+
export * from './disposable'

src/core/ioc/base.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { CoreContainer } from './container';
77
let containerSubject: CoreContainer<Partial<Dependencies>>;
88

99
/**
10+
* @deprecated
1011
* Returns the underlying data structure holding all dependencies.
1112
* Exposes methods from iti
13+
* Use the Service API. The container should be readonly
1214
*/
1315
export function useContainerRaw() {
1416
assert.ok(

src/core/ioc/container.ts

+26-33
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { Container } from 'iti';
2-
import { SernEmitter } from '../';
3-
import { isAsyncFunction } from 'node:util/types';
4-
2+
import { Disposable, SernEmitter } from '../';
53
import * as assert from 'node:assert';
64
import { Subject } from 'rxjs';
75
import { DefaultServices, ModuleStore } from '../_internal';
6+
import * as Hooks from './hooks'
7+
88

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

19-
this.listenForInsertions();
19+
const { unsubscribe } = Hooks.createInitListener(this);
20+
this.ready$
21+
.subscribe({ complete: unsubscribe });
2022

2123
(this as Container<{}, {}>)
2224
.add({
@@ -32,36 +34,27 @@ export class CoreContainer<T extends Partial<Dependencies>> extends Container<T,
3234
});
3335
}
3436

35-
private listenForInsertions() {
36-
assert.ok(
37-
!this.isReady(),
38-
'listening for init functions should only occur prior to sern being ready.',
39-
);
40-
const unsubscriber = this.on('containerUpserted', e => this.callInitHooks(e));
41-
42-
this.ready$.subscribe({
43-
complete: unsubscriber,
44-
});
45-
}
46-
47-
private async callInitHooks(e: { key: keyof T; newContainer: T[keyof T] | null }) {
48-
const dep = e.newContainer;
49-
50-
assert.ok(dep);
51-
//Ignore any dependencies that are not objects or array
52-
if (typeof dep !== 'object' || Array.isArray(dep)) {
53-
return;
54-
}
55-
if ('init' in dep && typeof dep.init === 'function' && !this.beenCalled.has(e.key)) {
56-
isAsyncFunction(dep.init) ? await dep.init() : dep.init();
57-
this.beenCalled.add(e.key);
58-
}
59-
}
6037

6138
isReady() {
39+
6240
return this.ready$.closed;
6341
}
42+
override async disposeAll() {
43+
44+
const otherDisposables = Object
45+
.entries(this._context)
46+
.flatMap(([key, value]) =>
47+
'dispose' in value
48+
? [key]
49+
: []);
50+
51+
for(const key of otherDisposables) {
52+
this.addDisposer({ [key]: (dep: Disposable) => dep.dispose() } as never);
53+
}
54+
await super.disposeAll()
55+
}
6456
ready() {
57+
this.ready$.complete();
6558
this.ready$.unsubscribe();
6659
}
6760
}

src/core/ioc/dependency-injection.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
2-
import { SernError, DefaultServices } from '../_internal';
2+
import { DefaultServices } from '../_internal';
33
import { useContainerRaw } from './base';
44
import { CoreContainer } from './container';
55

src/core/ioc/hooks.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { CoreContainer } from "./container"
2+
3+
interface HookEvent {
4+
key : PropertyKey
5+
newContainer: any
6+
}
7+
type HookName = 'init';
8+
9+
export const createInitListener = (coreContainer : CoreContainer<any>) => {
10+
const initCalled = new Set<PropertyKey>();
11+
const hasCallableMethod = createPredicate(initCalled);
12+
const unsubscribe = coreContainer.on('containerUpserted', async (event) => {
13+
14+
if(isNotHookable(event)) {
15+
return;
16+
}
17+
18+
if(hasCallableMethod('init', event)) {
19+
await event.newContainer?.init();
20+
initCalled.add(event.key);
21+
}
22+
23+
});
24+
return { unsubscribe };
25+
}
26+
27+
const isNotHookable = (hk: HookEvent) => {
28+
return typeof hk.newContainer !== 'object'
29+
|| Array.isArray(hk.newContainer)
30+
|| hk.newContainer === null;
31+
}
32+
33+
const createPredicate = <T extends HookEvent>(called: Set<PropertyKey>) => {
34+
return (hookName: HookName, event: T) => {
35+
const hasMethod = Reflect.has(event.newContainer!, hookName);
36+
const beenCalledOnce = !called.has(event.key)
37+
38+
return hasMethod && beenCalledOnce
39+
}
40+
}

src/core/ioc/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { useContainerRaw, makeDependencies } from './base';
1+
export { makeDependencies } from './base';
22
export { Service, Services, single, transient } from './dependency-injection';

src/core/structures/context.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,8 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
120120
}
121121

122122
function safeUnwrap<T>(res: Result<T, T>) {
123-
return res.unwrap()
123+
if(res.isOk()) {
124+
return res.expect("Tried unwrapping message field: " + res)
125+
}
126+
return res.expectErr("Tried unwrapping interaction field" + res)
124127
}

src/handlers/event-utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import {
2121
handleError,
2222
SernError,
2323
VoidResult,
24+
useContainerRaw,
2425
} from '../core/_internal';
25-
import { Emitter, ErrorHandling, Logging, ModuleManager, useContainerRaw } from '../core';
26+
import { Emitter, ErrorHandling, Logging, ModuleManager } from '../core';
2627
import { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
2728
import { ObservableInput, pipe } from 'rxjs';
2829
import { SernEmitter } from '../core';

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,7 @@ export {
5050
CommandExecutable,
5151
} from './core/modules';
5252

53+
export {
54+
useContainerRaw
55+
} from './core/_internal'
5356
export { controller } from './sern';

test/core/ioc.test.ts

+44-10
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { CoreContainer } from '../../src/core/ioc/container';
3-
import { CoreDependencies } from '../../src/core/ioc';
43
import { EventEmitter } from 'events';
5-
import { DefaultLogging, Init, Logging } from '../../src/core';
4+
import { DefaultLogging, Disposable, Init, Logging } from '../../src/core';
5+
import { CoreDependencies } from '../../src/types/ioc';
66

77
describe('ioc container', () => {
8-
let container: CoreContainer<{}>;
9-
let initDependency: Logging & Init;
8+
let container: CoreContainer<{}> = new CoreContainer();
9+
let dependency: Logging & Init & Disposable;
1010
beforeEach(() => {
11-
initDependency = {
11+
dependency = {
1212
init: vi.fn(),
1313
error(): void {},
1414
warning(): void {},
1515
info(): void {},
1616
debug(): void {},
17+
dispose: vi.fn()
1718
};
1819
container = new CoreContainer();
1920
});
20-
21+
const wait = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds));
22+
class DB implements Init, Disposable {
23+
public connected = false
24+
constructor() {}
25+
async init() {
26+
this.connected = true
27+
await wait(10)
28+
}
29+
async dispose() {
30+
await wait(20)
31+
this.connected = false
32+
}
33+
}
2134
it('should be ready after calling container.ready()', () => {
2235
container.ready();
2336
expect(container.isReady()).toBe(true);
@@ -39,14 +52,35 @@ describe('ioc container', () => {
3952
}
4053
});
4154
it('should init modules', () => {
42-
container.upsert({ '@sern/logger': initDependency });
55+
container.upsert({ '@sern/logger': dependency });
56+
container.ready();
57+
expect(dependency.init).to.toHaveBeenCalledOnce();
58+
});
59+
it('should dispose modules', async () => {
60+
61+
container.upsert({ '@sern/logger': dependency })
62+
4363
container.ready();
44-
expect(initDependency.init).to.toHaveBeenCalledOnce();
64+
// We need to access the dependency at least once to be able to dispose of it.
65+
container.get('@sern/logger' as never);
66+
await container.disposeAll();
67+
expect(dependency.dispose).toHaveBeenCalledOnce();
4568
});
4669

70+
it('should init and dispose', async () => {
71+
container.add({ db: new DB() })
72+
container.ready()
73+
const db = container.get('db' as never) as DB
74+
expect(db.connected).toBeTruthy()
75+
76+
await container.disposeAll();
77+
78+
expect(db.connected).toBeFalsy()
79+
})
80+
4781
it('should not lazy module', () => {
48-
container.upsert({ '@sern/logger': () => initDependency });
82+
container.upsert({ '@sern/logger': () => dependency });
4983
container.ready();
50-
expect(initDependency.init).toHaveBeenCalledTimes(0);
84+
expect(dependency.init).toHaveBeenCalledTimes(0);
5185
});
5286
});

test/core/services.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ describe('services', () => {
3939
.map((path, i) => `${path}/${modules[i]}.js`);
4040

4141
const metadata: CommandMeta[] = modules.map((cm, i) => ({
42-
id: Id.create(cm.name, cm.type),
42+
id: Id.create(cm.name!, cm.type),
4343
isClass: false,
4444
fullPath: `${paths[i]}/${cm.name}.js`,
4545
}));
4646
const moduleManager = container.get('@sern/modules');
4747
let i = 0;
4848
for (const m of modules) {
49-
moduleManager.set(Id.create(m.name, m.type), paths[i]);
49+
moduleManager.set(Id.create(m.name!, m.type), paths[i]);
5050
moduleManager.setMetadata(m, metadata[i]);
5151
i++;
5252
}

tsup.config.js

+3-28
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const shared = {
44
external: ['discord.js', 'iti'],
55
platform: 'node',
66
clean: true,
7-
sourcemap: false,
7+
sourcemap: true,
88
treeshake: {
99
moduleSideEffects: false,
1010
correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import
@@ -17,33 +17,8 @@ export default defineConfig([
1717
target: 'node18',
1818
tsconfig: './tsconfig.json',
1919
outDir: './dist',
20-
splitting: true,
20+
minify: false,
2121
dts: true,
2222
...shared,
2323
},
24-
// {
25-
// format: 'cjs',
26-
// esbuildPlugins: [ifdefPlugin({ variables: { MODE: 'cjs' }, verbose: true })],
27-
// splitting: false,
28-
// target: 'node18',
29-
// tsconfig: './tsconfig-cjs.json',
30-
// outDir: './dist/cjs',
31-
// outExtension() {
32-
// return {
33-
// js: '.cjs',
34-
// };
35-
// },
36-
// async onSuccess() {
37-
// console.log('writing json commonjs');
38-
// await writeFile('./dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }));
39-
// },
40-
// ...shared,
41-
// },
42-
// {
43-
// dts: {
44-
// only: true,
45-
// },
46-
// entry: ['src/index.ts'],
47-
// outDir: 'dist',
48-
// },
49-
]);
24+
]);

0 commit comments

Comments
 (0)