Compare commits

...

24 Commits

Author SHA1 Message Date
github-actions[bot]
504cdee7b2 chore(main): release 3.2.1 (#344)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-12-21 12:49:42 -06:00
Jacob Nguyen
c7661f272c chore: bump version 2023-12-21 12:47:24 -06:00
Jacob Nguyen
daac37c288 fix: logger swap failing 2023-12-21 12:47:02 -06:00
ysf
a579e272d0 revolutionary (#342) 2023-12-15 17:03:23 -06:00
github-actions[bot]
2051aa1ac0 chore(main): release 3.2.0 (#341)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-12-15 16:23:39 -06:00
Jacob Nguyen
237c8537c6 chore: release 3.2.0
Release-As: 3.2.0
2023-12-15 16:19:38 -06:00
Jacob Nguyen
77fb00d386 feat/abstractiti (#340)
* progress on better error handling

* wiring onError callback through module loader and resolver

* fix error callbacks not being stored

* update onError to be record

* type alias

* wiring

* seems to work

* update error handling contract and wire more

* add command error builder

* fix merge

* progress on error handling

* naive onError handling, not tested

* progres

* proress

* progress on abstracting away iti

* seems to work

* fix tests

* better typings

* add doc

* abstracting iti

* remove onerror for this pr

* feat: better way to add dependencies

* fix tests
2023-12-15 16:09:13 -06:00
renovate[bot]
89f6bbb975 chore(deps): update google-github-actions/release-please-action digest to db8f2c6 (#339)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-21 10:18:07 -06:00
github-actions[bot]
8ef4ee87e9 chore(main): release 3.1.1 (#338)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-05 21:25:18 -06:00
Neo
fd39858636 fix: queuing events (#332) @Benzo-Fury (#333)
fix: queuing events

Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-11-05 21:23:27 -06:00
Jacob Nguyen
132b625070 refactor: rm redudant fns and formatting 2023-11-04 16:57:13 -05:00
renovate[bot]
03439fec43 chore(deps): update google-github-actions/release-please-action digest to 4c5670f (#336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-21 22:30:40 -05:00
Jacob Nguyen
fc87e99ed0 Update README.md 2023-09-09 01:08:16 -05:00
renovate[bot]
a08541a8e7 chore(deps): update actions/checkout digest to f43a0e5 (#329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-04 17:06:17 -05:00
renovate[bot]
8bd5eb4949 chore(deps): lock file maintenance (#293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-04 17:05:33 -05:00
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
53 changed files with 1067 additions and 3179 deletions

View File

@@ -1,450 +0,0 @@
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
/* rules from the 'recommended' preset: */
{
name: 'no-circular',
severity: 'warn',
comment:
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
from: {},
to: {
circular: true,
},
},
{
name: 'no-orphans',
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
'add an exception for it in your dependency-cruiser configuration. By default ' +
'this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration ' +
'files (.d.ts), tsconfig.json and some of the babel and webpack configs.',
severity: 'warn',
from: {
orphan: true,
pathNot: [
'(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$', // dot files
'\\.d\\.ts$', // TypeScript declaration files
'(^|/)tsconfig\\.json$', // TypeScript config
'(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$', // other configs
],
},
to: {},
},
{
name: 'no-deprecated-core',
comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"bound to exist - node doesn't deprecate lightly.",
severity: 'warn',
from: {},
to: {
dependencyTypes: ['core'],
path: [
'^(v8/tools/codemap)$',
'^(v8/tools/consarray)$',
'^(v8/tools/csvparser)$',
'^(v8/tools/logreader)$',
'^(v8/tools/profile_view)$',
'^(v8/tools/profile)$',
'^(v8/tools/SourceMap)$',
'^(v8/tools/splaytree)$',
'^(v8/tools/tickprocessor-driver)$',
'^(v8/tools/tickprocessor)$',
'^(node-inspect/lib/_inspect)$',
'^(node-inspect/lib/internal/inspect_client)$',
'^(node-inspect/lib/internal/inspect_repl)$',
'^(async_hooks)$',
'^(punycode)$',
'^(domain)$',
'^(constants)$',
'^(sys)$',
'^(_linklist)$',
'^(_stream_wrap)$',
],
},
},
{
name: 'not-to-deprecated',
comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
from: {},
to: {
dependencyTypes: ['deprecated'],
},
},
{
name: 'no-non-package-json',
severity: 'error',
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
'available on live with an non-guaranteed version. Fix it by adding the package to the dependencies ' +
'in your package.json.',
from: {},
to: {
dependencyTypes: ['npm-no-pkg', 'npm-unknown'],
},
},
{
name: 'not-to-unresolvable',
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
from: {},
to: {
couldNotResolve: true,
},
},
{
name: 'no-duplicate-dep-types',
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
'in your package.json i.e. bot as a devDependencies and in dependencies. This will cause ' +
'maintenance problems later on.',
severity: 'warn',
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ['type-only'],
},
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-test',
comment:
"This module depends on code within a folder that should only contain tests. As tests don't " +
"implement functionality this is odd. Either you're writing a test outside the test folder " +
"or there's something in the test folder that isn't a test.",
severity: 'error',
from: {
pathNot: '^(test)',
},
to: {
path: '^(test)',
},
},
{
name: 'not-to-spec',
comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
from: {},
to: {
path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
},
},
{
name: 'not-to-dev-dep',
severity: 'error',
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
from: {
path: '^(src)',
pathNot: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
},
to: {
dependencyTypes: ['npm-dev'],
},
},
{
name: 'optional-deps-used',
severity: 'info',
comment:
'This module depends on an npm package that is declared as an optional dependency ' +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
'dependency-cruiser configuration.',
from: {},
to: {
dependencyTypes: ['npm-optional'],
},
},
{
name: 'peer-deps-used',
comment:
'This module depends on an npm package that is declared as a peer dependency ' +
'in your package.json. This makes sense if your package is e.g. a plugin, but in ' +
'other cases - maybe not so much. If the use of a peer dependency is intentional ' +
'add an exception to your dependency-cruiser configuration.',
severity: 'warn',
from: {},
to: {
dependencyTypes: ['npm-peer'],
},
},
],
options: {
/* conditions specifying which files not to follow further when encountered:
- path: a regular expression to match
- dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/main/doc/rules-reference.md#dependencytypes-and-dependencytypesnot
for a complete list
*/
doNotFollow: {
path: 'node_modules',
},
/* conditions specifying which dependencies to exclude
- path: a regular expression to match
- dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies.
leave out if you want to exclude neither (recommended!)
*/
// exclude : {
// path: '',
// dynamic: true
// },
/* pattern specifying which files to include (regular expression)
dependency-cruiser will skip everything not matching this pattern
*/
// includeOnly : '',
/* dependency-cruiser will include modules matching against the focus
regular expression in its output, as well as their neighbours (direct
dependencies and dependents)
*/
// focus : '',
/* list of module systems to cruise */
// moduleSystems: ['amd', 'cjs', 'es6', 'tsd'],
/* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/'
to open it on your online repo or `vscode://file/${process.cwd()}/` to
open it in visual studio code),
*/
// prefix: '',
/* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation
true: also detect dependencies that only exist before typescript-to-javascript compilation
"specify": for each dependency identify whether it only exists before compilation or also after
*/
tsPreCompilationDeps: true,
/*
list of extensions to scan that aren't javascript or compile-to-javascript.
Empty by default. Only put extensions in here that you want to take into
account that are _not_ parsable.
*/
// extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"],
/* if true combines the package.jsons found from the module up to the base
folder the cruise is initiated from. Useful for how (some) mono-repos
manage dependencies & dependency definitions.
*/
// combinedDependencies: false,
/* if true leave symlinks untouched, otherwise use the realpath */
// preserveSymlinks: false,
/* TypeScript project file ('tsconfig.json') to use for
(1) compilation and
(2) resolution (e.g. with the paths property)
The (optional) fileName attribute specifies which file to take (relative to
dependency-cruiser's current working directory). When not provided
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: 'tsconfig.json',
},
/* Webpack configuration to use to get resolve options from.
The (optional) fileName attribute specifies which file to take (relative
to dependency-cruiser's current working directory. When not provided defaults
to './webpack.conf.js'.
The (optional) `env` and `arguments` attributes contain the parameters to be passed if
your webpack config is a function and takes them (see webpack documentation
for details)
*/
// webpackConfig: {
// fileName: './webpack.config.js',
// env: {},
// arguments: {},
// },
/* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use
for compilation (and whatever other naughty things babel plugins do to
source code). This feature is well tested and usable, but might change
behavior a bit over time (e.g. more precise results for used module
systems) without dependency-cruiser getting a major version bump.
*/
// babelConfig: {
// fileName: './.babelrc'
// },
/* List of strings you have in use in addition to cjs/ es6 requires
& imports to declare module dependencies. Use this e.g. if you've
re-declared require, use a require-wrapper or use window.require as
a hack.
*/
// exoticRequireStrings: [],
/* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. You can set most of these
options in a webpack.conf.js - this section is here for those
projects that don't have a separate webpack config file.
Note: settings in webpack.conf.js override the ones specified here.
*/
enhancedResolveOptions: {
/* List of strings to consider as 'exports' fields in package.json. Use
['exports'] when you use packages that use such a field and your environment
supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack).
If you have an `exportsFields` attribute in your webpack config, that one
will have precedence over the one specified here.
*/
exportsFields: ['exports'],
/* List of conditions to check for in the exports field. e.g. use ['imports']
if you're only interested in exposed es6 modules, ['require'] for commonjs,
or all conditions at once `(['import', 'require', 'node', 'default']`)
if anything goes for you. Only works when the 'exportsFields' array is
non-empty.
If you have a 'conditionNames' attribute in your webpack config, that one will
have precedence over the one specified here.
*/
conditionNames: ['import', 'require', 'node', 'default'],
/*
The extensions, by default are the same as the ones dependency-cruiser
can access (run `npx depcruise --info` to see which ones that are in
_your_ environment. If that list is larger than what you need (e.g.
it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use
TypeScript you can pass just the extensions you actually use (e.g.
[".js", ".jsx"]). This can speed up the most expensive step in
dependency cruising (module resolution) quite a bit.
*/
// extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
/*
If your TypeScript project makes use of types specified in 'types'
fields in package.jsons of external dependencies, specify "types"
in addition to "main" in here, so enhanced-resolve (the resolver
dependency-cruiser uses) knows to also look there. You can also do
this if you're not sure, but still use TypeScript. In a future version
of dependency-cruiser this will likely become the default.
*/
mainFields: ['main', 'types'],
},
reporterOptions: {
dot: {
/* pattern of modules that can be consolidated in the detailed
graphical dependency graph. The default pattern in this configuration
collapses everything in node_modules to one folder deep so you see
the external modules, but not the innards your app depends upon.
*/
collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
don't worry - dependency-cruiser will fall back to the default one.
*/
// theme: {
// graph: {
// /* use splines: "ortho" for straight lines. Be aware though
// graphviz might take a long time calculating ortho(gonal)
// routings.
// */
// splines: "true"
// },
// modules: [
// {
// criteria: { matchesFocus: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesFocus: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { matchesReaches: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesReaches: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { source: "^src/model" },
// attributes: { fillcolor: "#ccccff" }
// },
// {
// criteria: { source: "^src/view" },
// attributes: { fillcolor: "#ccffcc" }
// },
// ],
// dependencies: [
// {
// criteria: { "rules[0].severity": "error" },
// attributes: { fontcolor: "red", color: "red" }
// },
// {
// criteria: { "rules[0].severity": "warn" },
// attributes: { fontcolor: "orange", color: "orange" }
// },
// {
// criteria: { "rules[0].severity": "info" },
// attributes: { fontcolor: "blue", color: "blue" }
// },
// {
// criteria: { resolved: "^src/model" },
// attributes: { color: "#0000ff77" }
// },
// {
// criteria: { resolved: "^src/view" },
// attributes: { color: "#00770077" }
// }
// ]
// }
},
archi: {
/* pattern of modules that can be consolidated in the high level
graphical dependency graph. If you use the high level graphical
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern:
'^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
for 'archi' dependency-cruiser will use the one specified in the
dot section (see above), if any, and otherwise use the default one.
*/
// theme: {
// },
},
text: {
highlightFocused: true,
},
},
},
};
// generated: dependency-cruiser@13.0.5 on 2023-07-08T03:48:00.632Z

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

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

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Set up Node.js
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
@@ -39,7 +39,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
with:
commit-message: "style: pretty please"
branch: prettier

View File

@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- 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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3
with:
node-version: 17
- run: yarn --immutable
- run: yarn build:prod
- uses: JS-DevTools/npm-publish@v1
- uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: "public"

View File

@@ -6,7 +6,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v3
- uses: google-github-actions/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3
with:
release-type: node
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/
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,35 @@
# Changelog
## [3.2.1](https://github.com/sern-handler/handler/compare/v3.2.0...v3.2.1) (2023-12-21)
### Bug Fixes
* logger swap failing ([daac37c](https://github.com/sern-handler/handler/commit/daac37c28858c42b21042bdcb8141239db634e7d))
## [3.2.0](https://github.com/sern-handler/handler/compare/v3.1.1...v3.2.0) (2023-12-15)
### Miscellaneous Chores
* release 3.2.0 ([237c853](https://github.com/sern-handler/handler/commit/237c8537c66052309d7e13a7e6e0a4f7995c2558))
## [3.1.1](https://github.com/sern-handler/handler/compare/v3.1.0...v3.1.1) (2023-11-06)
### Bug Fixes
* queuing events ([fd39858](https://github.com/sern-handler/handler/commit/fd39858636d3038abb6d91021b65c99c488a3d6e))
* queuing events ([#332](https://github.com/sern-handler/handler/issues/332)) @Benzo-Fury ([#333](https://github.com/sern-handler/handler/issues/333)) ([fd39858](https://github.com/sern-handler/handler/commit/fd39858636d3038abb6d91021b65c99c488a3d6e))
## [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)

View File

@@ -18,10 +18,9 @@
- For you. A framework that's tailored to your exact needs.
- Lightweight. Does a lot while being small.
- Latest features. Support for discord.js v14 and all of its interactions.
- Hybrid, customizable and composable commands. Create them just how you like.
- Start quickly. Plug and play or customize to your liking.
- Embraces reactive programming. For consistent and reliable backend.
- Switch and customize how errors are handled, logging, and more.
- works with [bun](https://bun.sh/) and [node](https://nodejs.org/en) out the box!
- Use it with TypeScript or JavaScript. CommonJS and ESM supported.
- Active and growing community, always here to help. [Join us](https://sern.dev/discord)
- Unleash its full potential with a powerful CLI and awesome plugins.
@@ -77,16 +76,14 @@ export default commandModule({
</details>
<details open><summary>index.ts</summary>
```ts
```ts
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
interface MyDependencies extends Dependencies {
'@sern/client': Singleton<Client>;
}
export const useContainer = Sern.makeDependencies<MyDependencies>({
//Version 3
await makeDependencies({
build: root => root
.add({ '@sern/client': single(() => client) })
});
@@ -96,9 +93,6 @@ Sern.init({
defaultPrefix: '!', // removing defaultPrefix will shut down text commands
commands: 'src/commands',
// events: 'src/events' (optional),
containerConfig : {
get: useContainer
}
});
client.login("YOUR_BOT_TOKEN_HERE");

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 119 KiB

0
fortnite Normal file
View File

View File

@@ -1,7 +1,7 @@
{
"name": "@sern/handler",
"packageManager": "yarn@3.5.0",
"version": "3.0.2",
"version": "3.2.1",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -15,11 +15,10 @@
},
"scripts": {
"watch": "tsup --watch",
"clean-modules": "rimraf node_modules/ && npm install",
"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",
@@ -40,15 +39,14 @@
"dependencies": {
"iti": "^0.6.0",
"rxjs": "^7.8.0",
"ts-results-es": "latest"
"ts-results-es": "^4.0.0"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.59.1",
"dependency-cruiser": "^13.0.5",
"discord.js": "14.11.0",
"discord.js": "^14.11.0",
"esbuild": "^0.17.0",
"eslint": "8.39.0",
"prettier": "2.8.8",

View File

@@ -1,5 +1,9 @@
{
"extends": ["config:base", "helpers:pinGitHubActionDigests", "group:allNonMajor"],
"extends": [
"config:base",
"helpers:pinGitHubActionDigests",
"group:allNonMajor"
],
"major": {
"dependencyDashboardApproval": true,
"reviewers": ["EvolutionX-10", "jacoobes", "Murtatrxx"]

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

@@ -1,21 +1,18 @@
import type { CommandModule,Processed, EventModule } from "../../types/core-modules";
/**
* @since 2.0.0
*/
export interface ErrorHandling {
/**
* Number of times the process should throw an error until crashing and exiting
*/
keepAlive: number;
/**
* @deprecated
* Version 4 will remove this method
*/
crash(err: Error): never;
/**
* A function that is called on every crash. Updates keepAlive.
* If keepAlive is 0, the process crashes.
* A function that is called on every throw.
* @param error
*/
updateAlive(error: Error): void;
}

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

@@ -6,13 +6,18 @@ import type {
} from '../../types/core-modules';
import { CommandType } from '../structures';
/**
* @since 2.0.0
*/
export interface ModuleManager {
get(id: string): string | undefined;
interface MetadataAccess {
getMetadata(m: Module): CommandMeta | undefined;
setMetadata(m: Module, c: CommandMeta): void;
}
/**
* @since 2.0.0
* @internal - direct access to the module manager will be removed in version 4
*/
export interface ModuleManager extends MetadataAccess {
get(id: string): string | undefined;
set(id: string, path: string): void;
getPublishableCommands(): Promise<CommandModule[]>;
getByNameCommandType<T extends CommandType>(

View File

@@ -36,7 +36,7 @@ export function partitionPlugins(
export function treeSearch(
iAutocomplete: AutocompleteInteraction,
options: SernOptionsData[] | undefined,
): SernAutocompleteData | undefined {
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
@@ -68,11 +68,11 @@ export function treeSearch(
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return cur;
return { ...cur, parent };
}
} else {
if (cur.name === choice.name) {
return cur;
return { ...cur, parent: undefined };
}
}
}

View File

@@ -2,13 +2,17 @@ import * as assert from 'assert';
import { composeRoot, useContainer } from './dependency-injection';
import type { DependencyConfiguration } from '../../types/ioc';
import { CoreContainer } from './container';
import { Result } from 'ts-results-es'
import { DefaultServices } from '../_internal';
import { AnyFunction } from '../../types/utility';
//SIDE EFFECT: GLOBAL DI
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(
@@ -18,17 +22,83 @@ export function useContainerRaw() {
return containerSubject;
}
/**
* @since 2.0.0
* @param conf a configuration for creating your project dependencies
*/
export async function makeDependencies<const T extends Dependencies>(
conf: DependencyConfiguration,
) {
const dependencyBuilder = (container: any, excluded: string[]) => {
type Insertable =
| ((container: CoreContainer<Dependencies>) => unknown )
| Record<PropertyKey, unknown>
return {
/**
* Insert a dependency into your container.
* Supply the correct key and dependency
*/
add(key: keyof Dependencies, v: Insertable) {
Result
.wrap(() => container.add({ [key]: v}))
.expect("Failed to add " + key);
},
/**
* Exclude any dependencies from being added.
* Warning: this could lead to bad errors if not used correctly
*/
exclude(...keys: (keyof Dependencies)[]) {
keys.forEach(key => excluded.push(key));
},
/**
* @param key the key of the dependency
* @param v The dependency to swap out.
* Swap out a preexisting dependency.
*/
swap(key: keyof Dependencies, v: Insertable) {
Result
.wrap(() => container.upsert({ [key]: v }))
.expect("Failed to update " + key);
},
/**
* @param key the key of the dependency
* @param cleanup Provide cleanup for the dependency at key. First parameter is the dependency itself
* @example
* ```ts
* addDisposer('dbConnection', (dbConnection) => dbConnection.end())
* ```
* Swap out a preexisting dependency.
*/
addDisposer(key: keyof Dependencies, cleanup: AnyFunction) {
Result
.wrap(() => container.addDisposer({ [key] : cleanup }))
.expect("Failed to addDisposer for" + key);
}
};
};
type CallbackBuilder = (c: ReturnType<typeof dependencyBuilder>) => any
type ValidDependencyConfig =
| CallbackBuilder
| DependencyConfiguration;
export const insertLogger = (containerSubject: CoreContainer<any>) => {
containerSubject
.upsert({'@sern/logger': () => new DefaultServices.DefaultLogging});
}
export async function makeDependencies<const T extends Dependencies>
(conf: ValidDependencyConfig) {
//Until there are more optional dependencies, just check if the logger exists
//SIDE EFFECT
containerSubject = new CoreContainer();
await composeRoot(containerSubject, conf);
if(typeof conf === 'function') {
const excluded: string[] = [];
conf(dependencyBuilder(containerSubject, excluded));
if(!excluded.includes('@sern/logger')
&& !containerSubject.getTokens()['@sern/logger']) {
insertLogger(containerSubject);
}
containerSubject.ready();
} else {
composeRoot(containerSubject, conf);
}
return useContainer<T>();
}

View File

@@ -1,29 +1,29 @@
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({
'@sern/errors': () => new DefaultServices.DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore(),
})
.add({ '@sern/errors': () => new DefaultServices.DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore() })
.add(ctx => {
return {
'@sern/modules': () =>
@@ -32,36 +32,23 @@ 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,6 +1,5 @@
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
import { SernError, DefaultServices } from '../_internal';
import { useContainerRaw } from './base';
import { insertLogger, useContainerRaw } from './base';
import { CoreContainer } from './container';
/**
@@ -53,25 +52,18 @@ export function Services<const T extends (keyof Dependencies)[]>(...keys: [...T]
* Finally, update the containerSubject with the new container state
* @param conf
*/
export async function composeRoot(
export function composeRoot(
container: CoreContainer<Partial<Dependencies>>,
conf: DependencyConfiguration,
) {
//container should have no client or logger yet.
const hasLogger = conf.exclude?.has('@sern/logger');
if (!hasLogger) {
container.upsert({
'@sern/logger': () => new DefaultServices.DefaultLogging(),
});
insertLogger(container);
}
//Build the container based on the callback provided by the user
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) {
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';

View File

@@ -23,18 +23,21 @@ export type ModuleResult<T> = Promise<ImportPayload<T>>;
* export default commandModule({})
*/
export async function importModule<T>(absPath: string) {
let module = await import(absPath).then(esm => esm.default);
let fileModule = await import(absPath);
assert(module, `Found no export for module at ${absPath}. Forgot to ignore with "!"? (!${basename(absPath)})?`);
if ('default' in module) {
module = module.default;
let commandModule = fileModule.default;
assert(commandModule , `Found no export @ ${absPath}. Forgot to ignore with "!"? (!${basename(absPath)})?`);
if ('default' in commandModule ) {
commandModule = commandModule.default;
}
return Result
.wrap(() => module.getInstance())
.unwrapOr(module) as T;
.wrap(() => ({ module: commandModule.getInstance() }))
.unwrapOr({ module: commandModule }) as T;
}
export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> {
let module = await importModule<T>(absPath);
let { module } = await importModule<{ module: T }>(absPath);
assert(module, `Found an undefined module: ${absPath}`);
return { module, absPath };
}
@@ -50,10 +53,11 @@ export const fmtFileName = (fileName: string) => parse(fileName).name;
export function buildModuleStream<T extends Module>(
input: ObservableInput<string>,
): Observable<ImportPayload<T>> {
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));
@@ -70,22 +74,18 @@ async function deriveFileInfo(dir: string, file: string) {
base: basename(file),
};
}
async function* readPaths(dir: string, shouldDebug: boolean): AsyncGenerator<string> {
async function* readPaths(dir: string): AsyncGenerator<string> {
try {
const files = await readdir(dir);
for (const file of files) {
const { fullPath, fileStats, base } = await deriveFileInfo(dir, file);
if (fileStats.isDirectory()) {
//Todo: refactor so that i dont repeat myself for files (line 71)
if (isSkippable(base)) {
if (shouldDebug) console.info(`ignored directory: ${fullPath}`);
} else {
yield* readPaths(fullPath, shouldDebug);
if (!isSkippable(base)) {
yield* readPaths(fullPath);
}
} else {
if (isSkippable(base)) {
if (shouldDebug) console.info(`ignored: ${fullPath}`);
} else {
if (!isSkippable(base)) {
yield 'file:///' + fullPath;
}
}
@@ -98,38 +98,30 @@ async function* readPaths(dir: string, shouldDebug: boolean): AsyncGenerator<str
const requir = createRequire(import.meta.url);
export function loadConfig(wrapper: Wrapper | 'file'): Wrapper {
if (wrapper === 'file') {
console.log('Experimental loading of sern.config.json');
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,
};
if (wrapper !== 'file') {
return wrapper;
}
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.
*/
export const everyPluginOk: OperatorFunction<VoidResult, boolean> = pipe(
every(result => result.ok),
every(result => result.isOk()),
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> =>
pipe(
concatMap(result => {
if(result.ok) {
return of(result.val)
if(result.isOk()) {
return of(result.value)
}
onErr(result.val);
onErr(result.error);
return EMPTY
})

View File

@@ -0,0 +1,41 @@
import type { ReplyOptions } from "../../types/utility";
import type { Logging } from "../contracts";
export interface Response {
type: 'fail' | 'continue';
body?: ReplyOptions;
log?: { type: keyof Logging; message: unknown }
}
export const of = () => {
const payload = {
type: 'fail',
body: undefined,
log : undefined
} as Record<PropertyKey, unknown>
return {
/**
* @param {'fail' | 'continue'} p a status to determine if the error will
* terminate your application or continue. Warning and
*/
status: (p: 'fail' | 'continue') => {
payload.type = p;
return payload;
},
/**
* @param {keyof Logging} type Determine to log to logger[type].
* @param {T} message the message to log
*
* Log this error with the logger.
*/
log: <T=string>(type: keyof Logging, message: T) => {
payload.log = { type, message };
return payload;
},
reply: (bodyContent: ReplyOptions) => {
payload.body = bodyContent;
return payload;
}
};
}

View File

@@ -11,8 +11,8 @@ import {
import { CoreContext } from '../structures/core-context';
import { Result, Ok, Err } from 'ts-results-es';
import * as assert from 'assert';
import { ReplyOptions } from '../../types/utility';
type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;
/**
* @since 1.0.0
@@ -31,52 +31,81 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
}
public get id(): Snowflake {
return this.ctx.val.id;
return safeUnwrap(this.ctx
.map(m => m.id)
.mapErr(i => i.id));
}
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
* else, interaction.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 {
return this.ctx.val.createdTimestamp;
return safeUnwrap(this.ctx
.map(m => m.createdTimestamp)
.mapErr(i => i.createdTimestamp));
}
public get guild() {
return this.ctx.val.guild;
return safeUnwrap(this.ctx
.map(m => m.guild)
.mapErr(i => i.guild));
}
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
*/
public get member() {
return this.ctx.val.member;
return safeUnwrap(this.ctx
.map(m => m.member)
.mapErr(i => i.member));
}
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 {
return this.ctx.val.inGuild();
return safeUnwrap(this.ctx
.map(m => m.inGuild())
.mapErr(i => i.inGuild()));
}
public async reply(content: ReplyOptions) {
return safeUnwrap(
this.ctx
.map(m => m.reply(content as string | MessageReplyOptions))
.map(m => m.reply(content as MessageReplyOptions))
.mapErr(i =>
i.reply(content as string | InteractionReplyOptions).then(() => i.fetchReply()),
i.reply(content as InteractionReplyOptions).then(() => i.fetchReply()),
),
);
}
@@ -91,5 +120,8 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
}
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> {
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 {
return this.ctx.expect(SernError.MismatchEvent);
@@ -17,7 +17,7 @@ export abstract class CoreContext<M, I> {
}
public isMessage(): this is CoreContext<M, never> {
return this.ctx.ok;
return this.ctx.isOk();
}
public isSlash(): this is CoreContext<never, I> {

View File

@@ -3,3 +3,4 @@ export * from './context';
export * from './sern-emitter';
export * from './services';
export * from './module-store';
export * as CommandError from './command-error';

View File

@@ -3,18 +3,18 @@ import { ErrorHandling } from '../../contracts';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
* Version 4.0.0 will internalize this api. Please refrain from using the defaults!
*/
export class DefaultErrorHandling implements ErrorHandling {
crash(err: Error): never {
throw err;
}
keepAlive = 5;
#keepAlive = 5;
updateAlive(err: Error) {
this.keepAlive--;
if (this.keepAlive === 0) {
this.#keepAlive--;
if (this.#keepAlive === 0) {
throw err;
}
}

View File

@@ -11,6 +11,7 @@ import { CommandType } from '../enums';
export class DefaultModuleManager implements ModuleManager {
constructor(private moduleStore: CoreModuleStore) {}
getByNameCommandType<T extends CommandType>(name: string, commandType: T) {
const id = this.get(Id.create(name, commandType));
if (!id) {

View File

@@ -9,20 +9,11 @@ import {
SernError,
} from '../core/_internal';
import { createResultResolver } from './event-utils';
import { AutocompleteInteraction, BaseInteraction, Message } from 'discord.js';
import { BaseInteraction, Message } from 'discord.js';
import { CommandType, Context } from '../core';
import type { Args } from '../types/utility';
import type { BothCommand, CommandModule, Module, Processed } from '../types/core-modules';
import type { AnyFunction, Args } from '../types/utility';
import type { CommandModule, Module, Processed } from '../types/core-modules';
function dispatchInteraction<T extends CommandModule, V extends BaseInteraction | Message>(
payload: { module: Processed<T>; event: V },
createArgs: (m: typeof payload.event) => unknown[],
) {
return {
module: payload.module,
args: createArgs(payload.event),
};
}
//TODO: refactor dispatchers so that it implements a strategy for each different type of payload?
export function dispatchMessage(module: Processed<CommandModule>, args: [Context, Args]) {
return {
@@ -31,41 +22,23 @@ export function dispatchMessage(module: Processed<CommandModule>, args: [Context
};
}
function dispatchAutocomplete(payload: {
module: Processed<BothCommand>;
event: AutocompleteInteraction;
}) {
const option = treeSearch(payload.event, payload.module.options);
assert.ok(
option,
Error(SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`),
);
return {
module: option.command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
};
}
export function contextArgs(wrappable: Message | BaseInteraction, messageArgs?: string[]) {
const ctx = Context.wrap(wrappable);
const args = ctx.isMessage() ? ['text', messageArgs!] : ['slash', ctx.options];
return [ctx, args] as [Context, Args];
}
function interactionArg<T extends BaseInteraction>(interaction: T) {
return [interaction] as [T];
}
function intoPayload(module: Processed<Module>) {
function intoPayload(module: Processed<Module>, ) {
return pipe(
arrayifySource,
map(args => ({ module, args })),
map(args => ({ module, args, })),
);
}
const createResult = createResultResolver<
Processed<Module>,
{ module: Processed<Module>; args: unknown[] },
{ module: Processed<Module>; args: unknown[] },
unknown[]
>({
createStream: ({ module, args }) => from(module.onEvent).pipe(callPlugin(args)),
@@ -76,7 +49,7 @@ const createResult = createResultResolver<
* @param module
* @param source
*/
export function eventDispatcher(module: Processed<Module>, source: unknown) {
export function eventDispatcher(module: Processed<Module>, source: unknown) {
assert.ok(source instanceof EventEmitter, `${source} is not an EventEmitter`);
const execute: OperatorFunction<unknown[], unknown> = concatMap(async args =>
@@ -101,17 +74,27 @@ export function createDispatcher(payload: {
case CommandType.Slash:
case CommandType.Both: {
if (isAutocomplete(payload.event)) {
/**
* Autocomplete is a special case that
* must be handled separately, since it's
* too different from regular command modules
* CAST SAFETY: payload is already guaranteed to be a slash command or both command
*/
return dispatchAutocomplete(payload as never);
const option = treeSearch(payload.event, payload.module.options);
assert.ok(
option,
Error(SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`),
);
const { command, name, parent } = option;
return {
...payload,
module: command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
};
}
return dispatchInteraction(payload, contextArgs);
return {
module: payload.module,
args: contextArgs(payload.event),
};
}
default:
return dispatchInteraction(payload, interactionArg);
default: return {
module: payload.module,
args: [payload.event],
};
}
}

View File

@@ -21,13 +21,14 @@ import {
handleError,
SernError,
VoidResult,
useContainerRaw,
} from '../core/_internal';
import { Emitter, ErrorHandling, Logging, ModuleManager, useContainerRaw } from '../core';
import { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
import { Emitter, ErrorHandling, Logging, ModuleManager } from '../core';
import { contextArgs, createDispatcher } from './dispatchers';
import { ObservableInput, pipe } from 'rxjs';
import { SernEmitter } from '../core';
import { Err, Ok, Result } from 'ts-results-es';
import type { Awaitable } from '../types/utility';
import type { AnyFunction, Awaitable } from '../types/utility';
import type { ControlPlugin } from '../types/core-plugin';
import type { AnyModule, CommandModule, Module, Processed } from '../types/core-modules';
import type { ImportPayload } from '../types/core';
@@ -77,8 +78,10 @@ export function createInteractionHandler<T extends Interaction>(
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(payload =>
Ok(createDispatcher({ module: payload.module, event }))
);
Ok(createDispatcher({
module: payload.module,
event,
})));
},
);
}
@@ -93,13 +96,14 @@ export function createMessageHandler(
const fullPath = mg.get(`${prefix}_A1`);
if(!fullPath) {
return Err('Possibly undefined behavior: could not find a static id to resolve ')
return Err('Possibly undefined behavior: could not find a static id to resolve')
}
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(payload => {
.then((payload)=> {
const args = contextArgs(event, rest);
return Ok(dispatchMessage(payload.module, args));
return Ok({ args, ...payload });
});
});
}
@@ -125,9 +129,17 @@ export function buildModules<T extends AnyModule>(
input: ObservableInput<string>,
moduleManager: ModuleManager,
) {
return Files.buildModuleStream<Processed<T>>(input).pipe(assignDefaults(moduleManager));
return Files
.buildModuleStream<Processed<T>>(input)
.pipe(assignDefaults(moduleManager));
}
interface ExecutePayload {
module: Processed<Module>;
task: () => Awaitable<unknown>;
args: unknown[]
}
/**
* Wraps the task in a Result as a try / catch.
* if the task is ok, an event is emitted and the stream becomes empty
@@ -138,24 +150,24 @@ export function buildModules<T extends AnyModule>(
*/
export function executeModule(
emitter: Emitter,
logger: Logging|undefined,
errHandler: ErrorHandling,
{
module,
task,
}: {
module: Processed<Module>;
task: () => Awaitable<unknown>;
},
args
}: ExecutePayload,
) {
return of(module).pipe(
//converting the task into a promise so rxjs can resolve the Awaitable properly
concatMap(() => Result.wrapAsync(async () => task())),
concatMap(result => {
if (result.ok) {
if (result.isOk()) {
emitter.emit('module.activate', SernEmitter.success(module));
return EMPTY;
} else {
return throwError(() => SernEmitter.failure(module, result.val));
}
}
return throwError(() => SernEmitter.failure(module, result.error));
}),
);
}
@@ -182,7 +194,7 @@ export function createResultResolver<
const task$ = config.createStream(args);
return task$.pipe(
tap(result => {
result.err && config.onStop?.(args.module);
result.isErr() && config.onStop?.(args.module);
}),
everyPluginOk,
filterMapTo(() => config.onNext(args)),
@@ -206,7 +218,7 @@ export function callInitPlugins<T extends Processed<AnyModule>>(sernEmitter: Emi
},
onNext: ({ module }) => {
sernEmitter.emit('module.register', SernEmitter.success(module));
return module;
return { module };
},
}),
);
@@ -218,16 +230,22 @@ export function callInitPlugins<T extends Processed<AnyModule>>(sernEmitter: Emi
*/
export function makeModuleExecutor<
M extends Processed<Module>,
Args extends { module: M; args: unknown[] },
Args extends {
module: M;
args: unknown[];
},
>(onStop: (m: M) => unknown) {
const onNext = ({ args, module }: Args) => ({
task: () => module.execute(...args),
module,
args
});
return concatMap(
createResultResolver({
onStop,
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
createStream: ({ args, module }) =>
from(module.onEvent)
.pipe(callPlugin(args)),
onNext,
}),
);

View File

@@ -1,5 +1,5 @@
import { Interaction } from 'discord.js';
import { concatMap, merge } from 'rxjs';
import { mergeMap, merge } from 'rxjs';
import { SernEmitter } from '../core';
import {
isAutocomplete,
@@ -13,7 +13,7 @@ import {
import { createInteractionHandler, executeModule, makeModuleExecutor } from './_internal';
import type { DependencyList } from '../types/ioc';
export function interactionHandler([emitter, , , modules, client]: DependencyList) {
export function interactionHandler([emitter, err, log, modules, client]: DependencyList) {
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, modules);
@@ -28,6 +28,6 @@ export function interactionHandler([emitter, , , modules, client]: DependencyLis
filterTap(e => emitter.emit('warning', SernEmitter.warning(e))),
makeModuleExecutor(module =>
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure))),
concatMap(payload => executeModule(emitter, payload)),
mergeMap(payload => executeModule(emitter, log, err, payload)),
);
}

View File

@@ -1,4 +1,4 @@
import { concatMap, EMPTY } from 'rxjs';
import { mergeMap, EMPTY } from 'rxjs';
import type { Message } from 'discord.js';
import { SernEmitter } from '../core';
import { sharedEventStream, SernError, filterTap } from '../core/_internal';
@@ -23,7 +23,7 @@ function hasPrefix(prefix: string, content: string) {
}
export function messageHandler(
[emitter, , log, modules, client]: DependencyList,
[emitter, err, log, modules, client]: DependencyList,
defaultPrefix: string | undefined,
) {
if (!defaultPrefix) {
@@ -42,6 +42,6 @@ export function messageHandler(
makeModuleExecutor(module => {
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure));
}),
concatMap(payload => executeModule(emitter, payload)),
mergeMap(payload => executeModule(emitter, log, err, payload)),
);
}

View File

@@ -17,10 +17,9 @@ export function startReadyEvent(
return concat(ready$, buildModules<AnyModule>(allPaths, moduleManager))
.pipe(callInitPlugins(sEmitter))
.subscribe(module => {
register(moduleManager, module).expect(
SernError.InvalidModuleType + ' ' + util.inspect(module),
);
.subscribe(({ module }) => {
register(moduleManager, module)
.expect(SernError.InvalidModuleType + ' ' + util.inspect(module));
});
}

View File

@@ -4,21 +4,21 @@ import { SernError } from '../core/_internal';
import { buildModules, callInitPlugins, handleCrash, eventDispatcher } from './_internal';
import { Service } from '../core/ioc';
import type { DependencyList } from '../types/ioc';
import type { CommandModule, EventModule, Processed } from '../types/core-modules';
import type { EventModule, Processed } from '../types/core-modules';
export function eventsHandler(
[emitter, err, log, moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
//code smell
const intoDispatcher = (e: Processed<EventModule | CommandModule>) => {
switch (e.type) {
const intoDispatcher = (e: { module: Processed<EventModule> }) => {
switch (e.module.type) {
case EventType.Sern:
return eventDispatcher(e, emitter);
return eventDispatcher(e.module, emitter);
case EventType.Discord:
return eventDispatcher(e, client);
return eventDispatcher(e.module, client);
case EventType.External:
return eventDispatcher(e, Service(e.emitter));
return eventDispatcher(e.module, Service(e.module.emitter));
default:
throw Error(SernError.InvalidModuleType + ' while creating event handler');
}
@@ -31,7 +31,6 @@ export function eventsHandler(
* Where all events are turned on
*/
mergeAll(),
handleCrash(err, log),
)
handleCrash(err, log))
.subscribe();
}

View File

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

View File

@@ -27,13 +27,12 @@ export function init(maybeWrapper: Wrapper | 'file') {
const dependencies = useDependencies();
const logger = dependencies[2],
errorHandler = dependencies[1];
const mode = isDevMode(wrapper.mode ?? process.env.MODE);
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
startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands, mode)).add(() => {
startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands)).add(() => {
const time = ((performance.now() - startTime) / 1000).toFixed(2);
dependencies[0].emit('modulesLoaded');
logger?.info({
@@ -47,14 +46,6 @@ export function init(maybeWrapper: Wrapper | 'file') {
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() {
return Services(
'@sern/emitter',

View File

@@ -19,6 +19,8 @@ import { CommandType, Context, EventType } from '../../src/core';
import { AnyCommandPlugin, AnyEventPlugin, ControlPlugin, InitPlugin } from './core-plugin';
import { Awaitable, Args, SlashOptions, SernEventsMapping } from './utility';
export interface CommandMeta {
fullPath: string;
id: string;

View File

@@ -1,3 +1,4 @@
export interface ImportPayload<T> {
module: T;
absPath: string;
@@ -10,8 +11,9 @@ export interface Wrapper {
events?: string;
/**
* 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
*/

View File

@@ -1,6 +1,6 @@
import { CommandInteractionOptionResolver } from 'discord.js';
import { PayloadType } from '../core';
import { AnyModule } from './core-modules';
import type { CommandInteractionOptionResolver, InteractionReplyOptions, MessageReplyOptions } from 'discord.js';
import type { PayloadType } from '../core';
import type { AnyModule } from './core-modules';
export type Awaitable<T> = PromiseLike<T> | T;
@@ -27,3 +27,7 @@ export type Payload =
| { type: PayloadType.Success; module: AnyModule }
| { type: PayloadType.Failure; module?: AnyModule; reason: string | Error }
| { type: PayloadType.Warning; reason: string };
export type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;

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 { 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

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

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

@@ -29,14 +29,14 @@ describe('eventDispatcher standard', () => {
});
it('should throw', () => {
expect(() => eventDispatcher(m, 'not event emitter')).toThrowError();
expect(() => eventDispatcher(m, 'not event emitter')).toThrowError();
});
it("Shouldn't throw", () => {
expect(() => eventDispatcher(m, ee)).not.toThrowError();
});
it('Should be called once', () => {
const s = eventDispatcher(m, ee);
const s = eventDispatcher(m, ee);
s.subscribe();
ee.emit(m.name, faker.string.alpha());

View File

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

View File

@@ -3,8 +3,6 @@
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"noImplicitAny": true,
"experimentalDecorators": true,
"strictNullChecks": true,
"moduleResolution": "node",
"skipLibCheck": true,

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

1378
yarn.lock

File diff suppressed because it is too large Load Diff