Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot]
e1059f93f7 chore(main): release 3.1.0 (#330)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-09-04 15:43:47 -05:00
renovate[bot]
800531453f chore(deps): pin dependencies (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-22 11:37:22 -05:00
Jacob Nguyen
c9f2d75665 deprecate: mode (#325)
* test: add tests for context

* deprecate: mode

* revert docs for deprecated option
2023-08-19 07:07:47 +05:30
Jacob Nguyen
e59e0b9d40 test: add tests for context (#324) 2023-08-18 10:46:46 -05:00
Jacob Nguyen
26ccd118ff feat: dispose hooks (deprecate useContainerRaw) (#323)
* feat: dispose hooks

* build: unminify, add source map, deprecate useContainerRaw

* fix regression of context and fix tsup build
2023-08-17 12:51:24 -05:00
Jacob Nguyen
4b97d86908 chore: upgrade ts-results-es (#322) 2023-08-13 10:55:39 -05:00
xxDeveloper
b1c82448bd chore: Create FUNDING.yml (#321)
* chore: Create FUNDING.yml

* Rename FUNDING.yml to .github/FUNDING.yml
2023-08-08 10:29:40 +03:00
Jacob Nguyen
d80081384a Update README.md 2023-08-07 17:23:58 -05:00
mina
50253ca322 feat: add guaranteed channelId and userId getters to Context (#320) 2023-08-06 15:28:38 -05:00
34 changed files with 341 additions and 1668 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
open_collective: sern

View File

@@ -39,7 +39,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: cpr id: cpr
uses: peter-evans/create-pull-request@v4 uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
with: with:
commit-message: "style: pretty please" commit-message: "style: pretty please"
branch: prettier branch: prettier

View File

@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@v3 uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@v2 uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2

View File

@@ -10,13 +10,13 @@ jobs:
test-and-publish: test-and-publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3
with: with:
node-version: 17 node-version: 17
- run: yarn --immutable - run: yarn --immutable
- run: yarn build:prod - run: yarn build:prod
- uses: JS-DevTools/npm-publish@v1 - uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with: with:
token: ${{ secrets.NPM_TOKEN }} token: ${{ secrets.NPM_TOKEN }}
access: "public" access: "public"

View File

@@ -6,7 +6,7 @@ jobs:
release-please: release-please:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: google-github-actions/release-please-action@v3 - uses: google-github-actions/release-please-action@ca6063f4ed81b55db15b8c42d1b6f7925866342d # v3
with: with:
release-type: node release-type: node
package-name: release-please-action package-name: release-please-action

View File

@@ -18,9 +18,9 @@ jobs:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## [3.1.0](https://github.com/sern-handler/handler/compare/v3.0.2...v3.1.0) (2023-09-04)
### Features
* add guaranteed `channelId` and `userId` getters to `Context` ([#320](https://github.com/sern-handler/handler/issues/320)) ([50253ca](https://github.com/sern-handler/handler/commit/50253ca322e7d6dbd2313139c0187a1028f71109))
* dispose hooks (deprecate useContainerRaw) ([#323](https://github.com/sern-handler/handler/issues/323)) ([26ccd11](https://github.com/sern-handler/handler/commit/26ccd118ff8cbcde94158a4d09fc0df18da9f254))
## [3.0.2](https://github.com/sern-handler/handler/compare/v3.0.1...v3.0.2) (2023-08-06) ## [3.0.2](https://github.com/sern-handler/handler/compare/v3.0.1...v3.0.2) (2023-08-06)

View File

@@ -77,16 +77,14 @@ export default commandModule({
</details> </details>
<details open><summary>index.ts</summary> <details open><summary>index.ts</summary>
```ts
```ts
import { Client, GatewayIntentBits } from 'discord.js'; import { Client, GatewayIntentBits } from 'discord.js';
import { Sern, single, type Dependencies } from '@sern/handler'; import { Sern, single } from '@sern/handler';
//client has been declared previously //client has been declared previously
//Version 3
interface MyDependencies extends Dependencies { await makeDependencies({
'@sern/client': Singleton<Client>;
}
export const useContainer = Sern.makeDependencies<MyDependencies>({
build: root => root build: root => root
.add({ '@sern/client': single(() => client) }) .add({ '@sern/client': single(() => client) })
}); });
@@ -96,9 +94,6 @@ Sern.init({
defaultPrefix: '!', // removing defaultPrefix will shut down text commands defaultPrefix: '!', // removing defaultPrefix will shut down text commands
commands: 'src/commands', commands: 'src/commands',
// events: 'src/events' (optional), // events: 'src/events' (optional),
containerConfig : {
get: useContainer
}
}); });
client.login("YOUR_BOT_TOKEN_HERE"); client.login("YOUR_BOT_TOKEN_HERE");

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "@sern/handler", "name": "@sern/handler",
"packageManager": "yarn@3.5.0", "packageManager": "yarn@3.5.0",
"version": "3.0.2", "version": "3.1.0",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.", "description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@@ -19,7 +19,7 @@
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"format": "eslint src/**/*.ts --fix", "format": "eslint src/**/*.ts --fix",
"build:dev": "tsup --metafile", "build:dev": "tsup --metafile",
"build:prod": "tsup --minify", "build:prod": "tsup ",
"prepare": "npm run build:prod", "prepare": "npm run build:prod",
"pretty": "prettier --write .", "pretty": "prettier --write .",
"tdd": "vitest", "tdd": "vitest",
@@ -40,7 +40,7 @@
"dependencies": { "dependencies": {
"iti": "^0.6.0", "iti": "^0.6.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"ts-results-es": "latest" "ts-results-es": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.0.1", "@faker-js/faker": "^8.0.1",

View File

@@ -7,3 +7,4 @@ export type { VoidResult } from '../types/core-plugin';
export { SernError } from './structures/enums'; export { SernError } from './structures/enums';
export { ModuleStore } from './structures/module-store'; export { ModuleStore } from './structures/module-store';
export * as DefaultServices from './structures/services'; 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 './module-store';
export * from './init'; export * from './init';
export * from './emitter'; export * from './emitter';
export * from './disposable'

View File

@@ -7,8 +7,10 @@ import { CoreContainer } from './container';
let containerSubject: CoreContainer<Partial<Dependencies>>; let containerSubject: CoreContainer<Partial<Dependencies>>;
/** /**
* @deprecated
* Returns the underlying data structure holding all dependencies. * Returns the underlying data structure holding all dependencies.
* Exposes methods from iti * Exposes methods from iti
* Use the Service API. The container should be readonly
*/ */
export function useContainerRaw() { export function useContainerRaw() {
assert.ok( assert.ok(

View File

@@ -1,22 +1,24 @@
import { Container } from 'iti'; import { Container } from 'iti';
import { SernEmitter } from '../'; import { Disposable, SernEmitter } from '../';
import { isAsyncFunction } from 'node:util/types';
import * as assert from 'node:assert'; import * as assert from 'node:assert';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { DefaultServices, ModuleStore } from '../_internal'; import { DefaultServices, ModuleStore } from '../_internal';
import * as Hooks from './hooks'
/** /**
* Provides all the defaults for sern to function properly. * A semi-generic container that provides error handling, emitter, and module store.
* The only user provided dependency needs to be @sern/client * 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, {}> { export class CoreContainer<T extends Partial<Dependencies>> extends Container<T, {}> {
private ready$ = new Subject<never>(); private ready$ = new Subject<void>();
private beenCalled = new Set<PropertyKey>();
constructor() { constructor() {
super(); 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<{}, {}>) (this as Container<{}, {}>)
.add({ .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() { isReady() {
return this.ready$.closed; 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() { ready() {
this.ready$.complete();
this.ready$.unsubscribe(); this.ready$.unsubscribe();
} }
} }

View File

@@ -1,5 +1,5 @@
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc'; import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
import { SernError, DefaultServices } from '../_internal'; import { DefaultServices } from '../_internal';
import { useContainerRaw } from './base'; import { useContainerRaw } from './base';
import { CoreContainer } from './container'; import { CoreContainer } from './container';
@@ -66,12 +66,7 @@ export async function composeRoot(
} }
//Build the container based on the callback provided by the user //Build the container based on the callback provided by the user
conf.build(container as CoreContainer<Omit<CoreDependencies, '@sern/client'>>); conf.build(container as CoreContainer<Omit<CoreDependencies, '@sern/client'>>);
try {
container.get('@sern/client');
} catch {
throw new Error(SernError.MissingRequired + ' No client was provided');
}
if (!hasLogger) { if (!hasLogger) {
container.get('@sern/logger')?.info({ message: 'All dependencies loaded successfully.' }); container.get('@sern/logger')?.info({ message: 'All dependencies loaded successfully.' });
} }

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'; export { Service, Services, single, transient } from './dependency-injection';

View File

@@ -33,6 +33,7 @@ export async function importModule<T>(absPath: string) {
.wrap(() => module.getInstance()) .wrap(() => module.getInstance())
.unwrapOr(module) as T; .unwrapOr(module) as T;
} }
export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> { export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> {
let module = await importModule<T>(absPath); let module = await importModule<T>(absPath);
assert(module, `Found an undefined module: ${absPath}`); assert(module, `Found an undefined module: ${absPath}`);
@@ -53,7 +54,7 @@ export function buildModuleStream<T extends Module>(
return from(input).pipe(mergeMap(defaultModuleLoader<T>)); return from(input).pipe(mergeMap(defaultModuleLoader<T>));
} }
export const getFullPathTree = (dir: string, mode: boolean) => readPaths(resolve(dir), mode); export const getFullPathTree = (dir: string) => readPaths(resolve(dir));
export const filename = (path: string) => fmtFileName(basename(path)); export const filename = (path: string) => fmtFileName(basename(path));
@@ -70,22 +71,18 @@ async function deriveFileInfo(dir: string, file: string) {
base: basename(file), base: basename(file),
}; };
} }
async function* readPaths(dir: string, shouldDebug: boolean): AsyncGenerator<string> { async function* readPaths(dir: string): AsyncGenerator<string> {
try { try {
const files = await readdir(dir); const files = await readdir(dir);
for (const file of files) { for (const file of files) {
const { fullPath, fileStats, base } = await deriveFileInfo(dir, file); const { fullPath, fileStats, base } = await deriveFileInfo(dir, file);
if (fileStats.isDirectory()) { if (fileStats.isDirectory()) {
//Todo: refactor so that i dont repeat myself for files (line 71) //Todo: refactor so that i dont repeat myself for files (line 71)
if (isSkippable(base)) { if (!isSkippable(base)) {
if (shouldDebug) console.info(`ignored directory: ${fullPath}`); yield* readPaths(fullPath);
} else {
yield* readPaths(fullPath, shouldDebug);
} }
} else { } else {
if (isSkippable(base)) { if (!isSkippable(base)) {
if (shouldDebug) console.info(`ignored: ${fullPath}`);
} else {
yield 'file:///' + fullPath; yield 'file:///' + fullPath;
} }
} }
@@ -98,38 +95,30 @@ async function* readPaths(dir: string, shouldDebug: boolean): AsyncGenerator<str
const requir = createRequire(import.meta.url); const requir = createRequire(import.meta.url);
export function loadConfig(wrapper: Wrapper | 'file'): Wrapper { export function loadConfig(wrapper: Wrapper | 'file'): Wrapper {
if (wrapper === 'file') { if (wrapper !== 'file') {
console.log('Experimental loading of sern.config.json'); return wrapper;
const config = requir(resolve('sern.config.json')) as {
language: string;
defaultPrefix?: string;
mode?: 'PROD' | 'DEV';
paths: {
base: string;
commands: string;
events?: string;
};
};
const makePath = (dir: keyof typeof config.paths) =>
config.language === 'typescript'
? join('dist', config.paths[dir]!)
: join(config.paths[dir]!);
console.log('Loading config: ', config);
const commandsPath = makePath('commands');
console.log('Commands path is set to', commandsPath);
let eventsPath: string | undefined;
if (config.paths.events) {
eventsPath = makePath('events');
console.log('Events path is set to', eventsPath);
}
return {
defaultPrefix: config.defaultPrefix,
commands: commandsPath,
events: eventsPath,
mode: config.mode,
};
} }
return wrapper; console.log('Experimental loading of sern.config.json');
const config = requir(resolve('sern.config.json'));
const makePath = (dir: PropertyKey) =>
config.language === 'typescript'
? join('dist', config.paths[dir]!)
: join(config.paths[dir]!);
console.log('Loading config: ', config);
const commandsPath = makePath('commands');
console.log('Commands path is set to', commandsPath);
let eventsPath: string | undefined;
if (config.paths.events) {
eventsPath = makePath('events');
console.log('Events path is set to', eventsPath);
}
return {
defaultPrefix: config.defaultPrefix,
commands: commandsPath,
events: eventsPath,
};
} }

View File

@@ -52,7 +52,7 @@ export const arrayifySource = map(src => (Array.isArray(src) ? (src as unknown[]
* Checks if the stream of results is all ok. * Checks if the stream of results is all ok.
*/ */
export const everyPluginOk: OperatorFunction<VoidResult, boolean> = pipe( export const everyPluginOk: OperatorFunction<VoidResult, boolean> = pipe(
every(result => result.ok), every(result => result.isOk()),
defaultIfEmpty(true), defaultIfEmpty(true),
); );
@@ -74,10 +74,10 @@ export function handleError<C>(crashHandler: ErrorHandling, logging?: Logging) {
export const filterTap = <K, R>(onErr: (e: R) => void): OperatorFunction<Result<K, R>, K> => export const filterTap = <K, R>(onErr: (e: R) => void): OperatorFunction<Result<K, R>, K> =>
pipe( pipe(
concatMap(result => { concatMap(result => {
if(result.ok) { if(result.isOk()) {
return of(result.val) return of(result.value)
} }
onErr(result.val); onErr(result.error);
return EMPTY return EMPTY
}) })

View File

@@ -31,44 +31,73 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
} }
public get id(): Snowflake { public get id(): Snowflake {
return this.ctx.val.id; return safeUnwrap(this.ctx
.map(m => m.id)
.mapErr(i => i.id));
} }
public get channel() { public get channel() {
return this.ctx.val.channel; return safeUnwrap(this.ctx
.map(m => m.channel)
.mapErr(i => i.channel));
} }
public get channelId(): Snowflake {
return safeUnwrap(this.ctx
.map(m => m.channelId)
.mapErr(i => i.channelId));
}
/** /**
* If context is holding a message, message.author * If context is holding a message, message.author
* else, interaction.user * else, interaction.user
*/ */
public get user(): User { public get user(): User {
return safeUnwrap(this.ctx.map(m => m.author).mapErr(i => i.user)); return safeUnwrap(this.ctx
.map(m => m.author)
.mapErr(i => i.user));
}
public get userId(): Snowflake {
return this.user.id;
} }
public get createdTimestamp(): number { public get createdTimestamp(): number {
return this.ctx.val.createdTimestamp; return safeUnwrap(this.ctx
.map(m => m.createdTimestamp)
.mapErr(i => i.createdTimestamp));
} }
public get guild() { public get guild() {
return this.ctx.val.guild; return safeUnwrap(this.ctx
.map(m => m.guild)
.mapErr(i => i.guild));
} }
public get guildId() { public get guildId() {
return this.ctx.val.guildId; return safeUnwrap(this.ctx
.map(m => m.guildId)
.mapErr(i => i.guildId));
} }
/* /*
* interactions can return APIGuildMember if the guild it is emitted from is not cached * interactions can return APIGuildMember if the guild it is emitted from is not cached
*/ */
public get member() { public get member() {
return this.ctx.val.member; return safeUnwrap(this.ctx
.map(m => m.member)
.mapErr(i => i.member));
} }
public get client(): Client { public get client(): Client {
return this.ctx.val.client; return safeUnwrap(this.ctx
.map(m => m.client)
.mapErr(i => i.client));
} }
public get inGuild(): boolean { public get inGuild(): boolean {
return this.ctx.val.inGuild(); return safeUnwrap(this.ctx
.map(m => m.inGuild())
.mapErr(i => i.inGuild()));
} }
public async reply(content: ReplyOptions) { public async reply(content: ReplyOptions) {
@@ -91,5 +120,8 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
} }
function safeUnwrap<T>(res: Result<T, T>) { function safeUnwrap<T>(res: Result<T, T>) {
return res.val; if(res.isOk()) {
return res.expect("Tried unwrapping message field: " + res)
}
return res.expectErr("Tried unwrapping interaction field" + res)
} }

View File

@@ -7,7 +7,7 @@ import * as assert from 'node:assert';
*/ */
export abstract class CoreContext<M, I> { export abstract class CoreContext<M, I> {
protected constructor(protected ctx: Either<M, I>) { protected constructor(protected ctx: Either<M, I>) {
assert.ok(typeof ctx.val === 'object' && ctx.val != null); assert.ok(typeof ctx === 'object' && ctx != null);
} }
get message(): M { get message(): M {
return this.ctx.expect(SernError.MismatchEvent); return this.ctx.expect(SernError.MismatchEvent);
@@ -17,7 +17,7 @@ export abstract class CoreContext<M, I> {
} }
public isMessage(): this is CoreContext<M, never> { public isMessage(): this is CoreContext<M, never> {
return this.ctx.ok; return this.ctx.isOk();
} }
public isSlash(): this is CoreContext<never, I> { public isSlash(): this is CoreContext<never, I> {

View File

@@ -21,8 +21,9 @@ import {
handleError, handleError,
SernError, SernError,
VoidResult, VoidResult,
useContainerRaw,
} from '../core/_internal'; } 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 { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
import { ObservableInput, pipe } from 'rxjs'; import { ObservableInput, pipe } from 'rxjs';
import { SernEmitter } from '../core'; import { SernEmitter } from '../core';
@@ -150,11 +151,11 @@ export function executeModule(
//converting the task into a promise so rxjs can resolve the Awaitable properly //converting the task into a promise so rxjs can resolve the Awaitable properly
concatMap(() => Result.wrapAsync(async () => task())), concatMap(() => Result.wrapAsync(async () => task())),
concatMap(result => { concatMap(result => {
if (result.ok) { if (result.isOk()) {
emitter.emit('module.activate', SernEmitter.success(module)); emitter.emit('module.activate', SernEmitter.success(module));
return EMPTY; return EMPTY;
} else { } else {
return throwError(() => SernEmitter.failure(module, result.val)); return throwError(() => SernEmitter.failure(module, result.error));
} }
}), }),
); );
@@ -182,7 +183,7 @@ export function createResultResolver<
const task$ = config.createStream(args); const task$ = config.createStream(args);
return task$.pipe( return task$.pipe(
tap(result => { tap(result => {
result.err && config.onStop?.(args.module); result.isErr() && config.onStop?.(args.module);
}), }),
everyPluginOk, everyPluginOk,
filterMapTo(() => config.onNext(args)), filterMapTo(() => config.onNext(args)),

View File

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

View File

@@ -27,13 +27,12 @@ export function init(maybeWrapper: Wrapper | 'file') {
const dependencies = useDependencies(); const dependencies = useDependencies();
const logger = dependencies[2], const logger = dependencies[2],
errorHandler = dependencies[1]; errorHandler = dependencies[1];
const mode = isDevMode(wrapper.mode ?? process.env.MODE);
if (wrapper.events !== undefined) { if (wrapper.events !== undefined) {
eventsHandler(dependencies, Files.getFullPathTree(wrapper.events, mode)); eventsHandler(dependencies, Files.getFullPathTree(wrapper.events));
} }
//Ready event: load all modules and when finished, time should be taken and logged //Ready event: load all modules and when finished, time should be taken and logged
startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands, mode)).add(() => { startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands)).add(() => {
const time = ((performance.now() - startTime) / 1000).toFixed(2); const time = ((performance.now() - startTime) / 1000).toFixed(2);
dependencies[0].emit('modulesLoaded'); dependencies[0].emit('modulesLoaded');
logger?.info({ logger?.info({
@@ -47,14 +46,6 @@ export function init(maybeWrapper: Wrapper | 'file') {
merge(messages$, interactions$).pipe(handleCrash(errorHandler, logger)).subscribe(); merge(messages$, interactions$).pipe(handleCrash(errorHandler, logger)).subscribe();
} }
function isDevMode(mode: string | undefined) {
console.info(`Detected mode: "${mode}"`);
if (mode === undefined) {
console.info('No mode found in process.env, assuming DEV');
}
return mode === 'DEV' || mode == undefined;
}
function useDependencies() { function useDependencies() {
return Services( return Services(
'@sern/emitter', '@sern/emitter',

View File

@@ -10,8 +10,9 @@ export interface Wrapper {
events?: string; events?: string;
/** /**
* Overload to enable mode in case developer does not use a .env file. * Overload to enable mode in case developer does not use a .env file.
* @deprecated - https://github.com/sern-handler/handler/pull/325
*/ */
mode?: 'DEV' | 'PROD'; mode?: string
/* /*
* @deprecated * @deprecated
*/ */

85
test/core/context.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { describe, vi, it, expect } from'vitest'
import { Context } from '../../src';
import { faker } from '@faker-js/faker'
describe('Context', () => {
// Mocked message and interaction objects for testing
const mockMessage = {
id: 'messageId',
channel: 'channelId',
channelId: 'channelId',
interaction: {
id: faker.string.uuid()
},
author: { id: 'userId' },
createdTimestamp: 1234567890,
guild: 'guildId',
guildId: 'guildId',
member: { id: 'memberId' },
client: { id: 'clientId' },
inGuild: vi.fn().mockReturnValue(true),
reply: vi.fn(),
};
const mockInteraction = {
id: 'interactionId',
user: { id: 'userId' },
channel: 'channelId',
channelId: 'channelId',
createdTimestamp: 1234567890,
guild: 'guildId',
guildId: 'guildId',
fetchReply: vi.fn().mockResolvedValue({}),
member: { id: 'memberId' },
client: { id: 'clientId' },
isChatInputCommand: vi.fn().mockResolvedValue(true),
inGuild: vi.fn().mockReturnValue(true),
reply: vi.fn().mockResolvedValue({}),
};
it('should create a context from a message', () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
expect(context).toBeDefined();
expect(context.id).toBe('messageId');
});
it('should throw error if accessing interaction as message', () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
expect(context).toBeDefined();
expect(() => context.interaction)
.toThrowError('You cannot use message when an interaction fired or vice versa');
})
it('should throw error if accessing message as interaction', () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
expect(context).toBeDefined();
expect(() => context.message)
.toThrowError('You cannot use message when an interaction fired or vice versa');
})
it('should create a context from an interaction', () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
expect(context).toBeDefined();
expect(context.id).toBe('interactionId');
});
it('should reply to a context with a message', async () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
const replyOptions = { content: 'Hello, world!' };
await context.reply(replyOptions);
expect(mockMessage.reply).toHaveBeenCalledWith(replyOptions);
});
it('should reply to a context with an interaction', async () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
const replyOptions = { content: 'Hello, world!' };
await context.reply(replyOptions);
expect(mockInteraction.reply).toHaveBeenCalledWith(replyOptions);
});
});

View File

@@ -1,23 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreContainer } from '../../src/core/ioc/container'; import { CoreContainer } from '../../src/core/ioc/container';
import { CoreDependencies } from '../../src/core/ioc';
import { EventEmitter } from 'events'; 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', () => { describe('ioc container', () => {
let container: CoreContainer<{}>; let container: CoreContainer<{}> = new CoreContainer();
let initDependency: Logging & Init; let dependency: Logging & Init & Disposable;
beforeEach(() => { beforeEach(() => {
initDependency = { dependency = {
init: vi.fn(), init: vi.fn(),
error(): void {}, error(): void {},
warning(): void {}, warning(): void {},
info(): void {}, info(): void {},
debug(): void {}, debug(): void {},
dispose: vi.fn()
}; };
container = new CoreContainer(); 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()', () => { it('should be ready after calling container.ready()', () => {
container.ready(); container.ready();
expect(container.isReady()).toBe(true); expect(container.isReady()).toBe(true);
@@ -39,14 +52,35 @@ describe('ioc container', () => {
} }
}); });
it('should init modules', () => { it('should init modules', () => {
container.upsert({ '@sern/logger': initDependency }); container.upsert({ '@sern/logger': dependency });
container.ready(); 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', () => { it('should not lazy module', () => {
container.upsert({ '@sern/logger': () => initDependency }); container.upsert({ '@sern/logger': () => dependency });
container.ready(); container.ready();
expect(initDependency.init).toHaveBeenCalledTimes(0); expect(dependency.init).toHaveBeenCalledTimes(0);
}); });
}); });

View File

@@ -8,7 +8,6 @@ describe('module-loading', () => {
const filename = Files.fmtFileName(name+'.'+extension); const filename = Files.fmtFileName(name+'.'+extension);
expect(filename).toBe(name) expect(filename).toBe(name)
}) })
// todo: handle commands with multiple extensions // todo: handle commands with multiple extensions
// it('should properly extract filename from file, nested multiple', () => { // it('should properly extract filename from file, nested multiple', () => {

View File

@@ -39,14 +39,14 @@ describe('services', () => {
.map((path, i) => `${path}/${modules[i]}.js`); .map((path, i) => `${path}/${modules[i]}.js`);
const metadata: CommandMeta[] = modules.map((cm, i) => ({ const metadata: CommandMeta[] = modules.map((cm, i) => ({
id: Id.create(cm.name, cm.type), id: Id.create(cm.name!, cm.type),
isClass: false, isClass: false,
fullPath: `${paths[i]}/${cm.name}.js`, fullPath: `${paths[i]}/${cm.name}.js`,
})); }));
const moduleManager = container.get('@sern/modules'); const moduleManager = container.get('@sern/modules');
let i = 0; let i = 0;
for (const m of modules) { 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]); moduleManager.setMetadata(m, metadata[i]);
i++; i++;
} }

View File

@@ -18,7 +18,7 @@ function createRandomCommandModules() {
CommandType.Button, CommandType.Button,
]; ];
return commandModule({ return commandModule({
type: randomCommandType[Math.floor(Math.random() * randomCommandType.length)], type: faker.helpers.uniqueArray(randomCommandType, 1)[0],
description: faker.string.alpha(), description: faker.string.alpha(),
name: faker.string.alpha(), name: faker.string.alpha(),
execute: () => {}, execute: () => {},

View File

@@ -4,7 +4,7 @@ const shared = {
external: ['discord.js', 'iti'], external: ['discord.js', 'iti'],
platform: 'node', platform: 'node',
clean: true, clean: true,
sourcemap: false, sourcemap: true,
treeshake: { treeshake: {
moduleSideEffects: false, moduleSideEffects: false,
correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import
@@ -17,33 +17,8 @@ export default defineConfig([
target: 'node18', target: 'node18',
tsconfig: './tsconfig.json', tsconfig: './tsconfig.json',
outDir: './dist', outDir: './dist',
splitting: true, minify: false,
dts: true, dts: true,
...shared, ...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',
// },
]);

View File

@@ -619,7 +619,7 @@ __metadata:
iti: ^0.6.0 iti: ^0.6.0
prettier: 2.8.8 prettier: 2.8.8
rxjs: ^7.8.0 rxjs: ^7.8.0
ts-results-es: latest ts-results-es: ^4.0.0
tsup: ^6.7.0 tsup: ^6.7.0
typescript: 5.0.2 typescript: 5.0.2
vitest: latest vitest: latest
@@ -3844,10 +3844,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ts-results-es@npm:latest": "ts-results-es@npm:^4.0.0":
version: 3.6.1 version: 4.0.0
resolution: "ts-results-es@npm:3.6.1" resolution: "ts-results-es@npm:4.0.0"
checksum: af0d93ee4d3bd9e99a5fd4ac4b0ad090aef0a61e1f38ee596cfebe8d47090b34a2557d3778e00b4aae7c74962133805275ffffe56716e4d747fa559a926d9ced checksum: 32a7059491e36d06c5a1084fe9be8021a0beb2d94a94b0c3fa85dc3e96561bf34fb8fd60ebe661064c9fc2bafcf437b6b65f119e8d7497af7f76cda9d9a2a945
languageName: node languageName: node
linkType: hard linkType: hard