diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..663fbf4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fce5a6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Build + run: pnpm run build + + - name: Test + run: pnpm run test + + - name: Coverage + run: pnpm run coverage + if: matrix.node-version == '22.x' + + - name: Upload coverage + uses: codecov/codecov-action@v4 + if: matrix.node-version == '22.x' + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..50e1925 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.6] - 2025-02-24 + +### Changed +- Migrated build tool from rollup to rslib +- Updated publish script + +## [1.1.5] - 2025-02-09 + +### Dependencies +- Updated bittydash to v0.7.1 + +## [1.1.4] - 2025-02-22 + +### Dependencies +- Updated prettier to v3.5.2 + +## [1.1.0] - 2022-11-28 + +### Added +- Initial release with core event subscriber and dispatcher functionality +- Scope isolation support +- Wildcard event listener (`*`) +- `once` option for one-time listeners + +[1.1.6]: https://github.com/Yukiniro/miis/compare/v1.1.5...v1.1.6 +[1.1.5]: https://github.com/Yukiniro/miis/compare/v1.1.4...v1.1.5 +[1.1.4]: https://github.com/Yukiniro/miis/compare/v1.1.3...v1.1.4 +[1.1.0]: https://github.com/Yukiniro/miis/releases/tag/v1.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..250ec5d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present Yukiniro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2004f3b..9768f32 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,131 @@ -# miis - -![NPM](https://img.shields.io/npm/l/miis?color=blue&style=flat-square) ![npm](https://img.shields.io/npm/v/miis?color=blue&style=flat-square) - -> The `miis` is a tiny functional event subscriber and dispatcher. - -## Features - -- **Tiny**: weighs less than 1kb gzipped -- **Plentiful**: a special "\*" event type listens to all events -- **Scope**: isolate different listening environments by setting scope - -## Install - -This project need node and npm. - -```shell -npm install miis --save -``` - -or - -```shell -pnpm add miis --save -``` - -## Useage - -```javascript -import miis from "miis"; - -miis.subscribe("a", (...args) => { - console.log("a event call"); // a event call - console.log(...args); /// 1, 2, 3 -}); -miis.dispatch("a", 1, 2, 3); -``` - -And it's so easy to operate with react. Here is a [demo](https://stackblitz.com/edit/react-ts-ucliuq?file=App.tsx). - -```jsx -import * as React from "react"; -import "./style.css"; -import miis from "miis"; - -export default function App() { - const [count, setCount] = React.useState(0); - React.useEffect(() => { - return miis.subscribe("a", () => { - setCount(count + 1); - }); - }, [count]); - - const handleClick = () => { - miis.dispatch("a"); - }; - - return ( -
- -

Count: {count}

-
- ); -} -``` - -You could unsubscribe the event lisenter with the result of subscribe. - -```javascript -import miis from "miis"; - -const unsubscribe = miis.subscribe("a", () => { - console.log("a event call"); -}); -unsubscribe(); - -miis.dispatch("a"); // not work -``` - -## API - -### subscirbe - -Register an event listenter for the given name. - -#### Params - -- `eventName` **string | symbol** Name of event to listen for.(_`*`_ for all events) -- `listenter` **Function** Function to call in response to given event -- `options` **undefined | Object** Some options. _optional_ - - `once` **boolean** Only call once if it is `true`. - -#### Returns - -- `unsubscribe` **Function** Function to remove the listenter. - -### dispatch - -Invoke all handlers for the given name. - -#### Params - -- `eventName` **string | symbol** Name of event to invoke for. - -### clear - -Clears the specified listeners. It will clear all listeners if the parameter is undefined. - -#### Params - -- `eventName` **string | symbol | undefiend** Name of event to listen for.(_undefined_ for all events) - -### setScope - -If you call `dispatch`, only the handlers for the given scope will be invoked. - -#### Params - -- `scope` **string** Name of scope. - -### getScope - -Return current `scope`. - -### resetScope - -Reset the scope to be `default`. +# miis + +![NPM](https://img.shields.io/npm/l/miis?color=blue&style=flat-square) ![npm](https://img.shields.io/npm/v/miis?color=blue&style=flat-square) [![CI](https://github.com/Yukiniro/miis/actions/workflows/ci.yml/badge.svg)](https://github.com/Yukiniro/miis/actions/workflows/ci.yml) + +> The `miis` is a tiny functional event subscriber and dispatcher. + +## Features + +- **Tiny**: weighs less than 1kb gzipped +- **Plentiful**: a special "*" event type listens to all events +- **Scope**: isolate different listening environments by setting scope + +## Install + +This project needs node and npm. + +```shell +npm install miis --save +``` + +or + +```shell +pnpm add miis --save +``` + +## Usage + +```javascript +import miis from "miis"; + +miis.subscribe("a", (...args) => { + console.log("a event call"); // a event call + console.log(...args); // 1, 2, 3 +}); +miis.dispatch("a", 1, 2, 3); +``` + +And it's so easy to operate with React. Here is a [demo](https://stackblitz.com/edit/react-ts-ucliuq?file=App.tsx). + +```jsx +import * as React from "react"; +import "./style.css"; +import miis from "miis"; + +export default function App() { + const [count, setCount] = React.useState(0); + React.useEffect(() => { + return miis.subscribe("a", () => { + setCount(count + 1); + }); + }, [count]); + + const handleClick = () => { + miis.dispatch("a"); + }; + + return ( +
+ +

Count: {count}

+
+ ); +} +``` + +You could unsubscribe the event listener with the result of subscribe. + +```javascript +import miis from "miis"; + +const unsubscribe = miis.subscribe("a", () => { + console.log("a event call"); +}); +unsubscribe(); + +miis.dispatch("a"); // not work +``` + +## API + +### subscribe + +Register an event listener for the given name. + +#### Params + +- `eventName` **string | symbol** Name of event to listen for.(_`*`_ for all events) +- `listener` **Function** Function to call in response to given event +- `options` **undefined | Object** Some options. _optional_ + - `once` **boolean** Only call once if it is `true`. + +#### Returns + +- `unsubscribe` **Function** Function to remove the listener. + +### dispatch + +Invoke all handlers for the given name. + +#### Params + +- `eventName` **string | symbol** Name of event to invoke for. + +### clear + +Clears the specified listeners. It will clear all listeners if the parameter is undefined. + +#### Params + +- `eventName` **string | symbol | undefined** Name of event to clear.(_undefined_ for all events) + +### setScope + +If you call `dispatch`, only the handlers for the given scope will be invoked. + +#### Params + +- `scope` **string** Name of scope. + +### getScope + +Return current `scope`. + +### resetScope + +Reset the scope to be `default`. + +## License + +[MIT](./LICENSE) diff --git a/package.json b/package.json index abc5fc8..dd48aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "miis", - "version": "1.1.6", + "version": "1.2.0", "description": "Tiny functional event subscriber and dispatcher.", "main": "dist/index.js", "module": "dist/index.mjs", @@ -19,7 +19,9 @@ "emitter", "subscribe", "dispatch", - "linstener" + "listener", + "eventbus", + "pubsub" ], "author": "Yukiniro", "license": "MIT", @@ -39,8 +41,5 @@ "typescript-eslint": "^8.57.1", "vitest": "^3.2.4" }, - "dependencies": { - "bittydash": "^0.7.1" - }, "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17bcdaa..572da89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - bittydash: - specifier: ^0.7.1 - version: 0.7.1 devDependencies: '@eslint/js': specifier: ^9.19.0 @@ -768,9 +764,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bittydash@0.7.1: - resolution: {integrity: sha512-zsrD4PaWzuOc6TIahZaF081DrPTLIwm0s8im2oxU5oqnB2HdKKbMgFJb/AEqfb7sQY7sJC4zwdQSGxxc7shJFQ==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1938,8 +1931,6 @@ snapshots: balanced-match@4.0.4: {} - bittydash@0.7.1: {} - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 diff --git a/renovate.json b/renovate.json index 39a2b6e..394c693 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,21 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ] + "extends": ["config:base"], + "prConcurrentLimit": 5, + "prHourlyLimit": 2, + "packageRules": [ + { + "matchUpdateTypes": ["patch"], + "automerge": true, + "automergeType": "pr" + }, + { + "matchUpdateTypes": ["minor"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "pr" + } + ], + "ignoreDeps": [], + "labels": ["dependencies"] } diff --git a/src/index.ts b/src/index.ts index 6c2fe5f..d810951 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -import { remove, isUndefined } from 'bittydash'; - type Listener = (...args: unknown[]) => void; type EventName = string | symbol; type Options = { @@ -9,13 +7,24 @@ type Options = { type Subscriber = { listener: Listener; once?: boolean; - scope?: string; + scope: string; }; const ALL_WILD_KEY = '*'; -const handlerMap = new Map(); +const handlerMap = new Map(); let curScope = 'default'; +function removeItem(arr: T[], item: T): void { + const idx = arr.indexOf(item); + if (idx > -1) { + arr.splice(idx, 1); + } +} + +function isUndefined(value: unknown): value is undefined { + return value === undefined; +} + function subscribe( key: EventName, listener: Listener, @@ -24,20 +33,20 @@ function subscribe( if (!handlerMap.has(key)) { handlerMap.set(key, []); } - const list = handlerMap.get(key); + const list = handlerMap.get(key)!; const { once, scope } = options || {}; - const item = { + const item: Subscriber = { listener, once, scope: isUndefined(scope) ? 'default' : scope, }; list.push(item); return () => { - remove(list, item); + removeItem(list, item); }; } -function dispatch(key: EventName, ...args: unknown[]) { +function dispatch(key: EventName, ...args: unknown[]): void { const trigger = (list: Subscriber[]) => { const removeList: Subscriber[] = []; list.filter((item) => item.scope === curScope).forEach( @@ -49,17 +58,17 @@ function dispatch(key: EventName, ...args: unknown[]) { } }, ); - removeList.forEach((item: Subscriber) => remove(list, item)); + removeList.forEach((item: Subscriber) => removeItem(list, item)); }; if (handlerMap.has(key)) { - trigger(handlerMap.get(key)); + trigger(handlerMap.get(key)!); } if (key !== ALL_WILD_KEY && handlerMap.has(ALL_WILD_KEY)) { - trigger(handlerMap.get(ALL_WILD_KEY)); + trigger(handlerMap.get(ALL_WILD_KEY)!); } } -function clear(key?: EventName) { +function clear(key?: EventName): void { if (isUndefined(key)) { handlerMap.clear(); } else { @@ -67,7 +76,7 @@ function clear(key?: EventName) { } } -function setScope(scope: string) { +function setScope(scope: string): void { curScope = scope; } @@ -75,7 +84,7 @@ function getScope(): string { return curScope; } -function resetScope() { +function resetScope(): void { curScope = 'default'; } diff --git a/tests/index.test.ts b/tests/index.test.ts index caadce9..0132508 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,106 +1,150 @@ -import { afterEach, describe } from 'vitest'; +import { afterEach, describe, beforeEach } from 'vitest'; import { test, expect } from 'vitest'; import miis from '../src'; afterEach(() => { - miis.clear(); + miis.clear(); + miis.resetScope(); }); -describe('basic useage', () => { - test('subscribe', () => { - let count = 0; - miis.subscribe('a', () => count++); - miis.dispatch('a'); - miis.dispatch('a'); - expect(count).toBe(2); - }); - - test('unsubscribe', async () => { - await new Promise((resolve, reject) => { - const unsubscribe = miis.subscribe('b', reject); - unsubscribe(); - miis.dispatch('b'); - setTimeout(resolve, 10); - }); - }); - - test('clear', async () => { - await new Promise((resolve, reject) => { - miis.subscribe('a', reject); - miis.clear(); - miis.dispatch('a'); - setTimeout(resolve, 10); - }); - - await new Promise((resolve, reject) => { - miis.subscribe('a', reject); - miis.clear('a'); - miis.dispatch('a'); - setTimeout(resolve, 10); - }); - }); - - test('once', async () => { - let tag = 0; - await new Promise((resolve) => { - miis.subscribe( - 'a', - (value) => { - tag++; - resolve(value); - }, - { once: true }, - ); - miis.dispatch('a'); - }); - miis.dispatch('a'); - expect(tag).toBe(1); - }); +describe('basic usage', () => { + test('subscribe', () => { + let result = ''; + miis.subscribe('a', (...args) => { + result = args.join(','); + }); + miis.dispatch('a', 1, 2, 3); + expect(result).toBe('1,2,3'); + }); + + test('dispatch with no listener', () => { + expect(() => miis.dispatch('nonexistent')).not.toThrow(); + }); + + test('dispatch with multiple listeners', () => { + let count = 0; + miis.subscribe('a', () => count++); + miis.subscribe('a', () => count++); + miis.dispatch('a'); + expect(count).toBe(2); + }); +}); + +describe('unsubscribe', () => { + test('unsubscribe', () => { + let count = 0; + const unsubscribe = miis.subscribe('a', () => { + count++; + }); + unsubscribe(); + miis.dispatch('a'); + expect(count).toBe(0); + }); + + test('unsubscribe twice should be safe', () => { + const unsubscribe = miis.subscribe('a', () => {}); + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + }); +}); + +describe('once', () => { + test('once', () => { + let count = 0; + miis.subscribe( + 'a', + () => { + count++; + }, + { once: true }, + ); + miis.dispatch('a'); + miis.dispatch('a'); + expect(count).toBe(1); + }); +}); + +describe('wildcard', () => { + test('wildcard listens to all events', () => { + let count = 0; + miis.subscribe('*', () => { + count++; + }); + miis.dispatch('a'); + miis.dispatch('b'); + expect(count).toBe(2); + }); + + test('wildcard should not trigger on itself', () => { + let count = 0; + miis.subscribe('*', () => count++); + miis.dispatch('*'); + expect(count).toBe(1); + }); }); -describe('arguments', () => { - test('single', async () => { - const result = await new Promise((resolve) => { - miis.subscribe('b', resolve); - miis.dispatch('b', 1); +describe('scope', () => { + beforeEach(() => { + miis.resetScope(); + }); + + test('setScope', () => { + miis.setScope('test'); + expect(miis.getScope()).toBe('test'); + }); + + test('getScope default', () => { + expect(miis.getScope()).toBe('default'); }); - expect(result).toBe(1); - }); - test('multi', async () => { - const result = await new Promise((resolve) => { - miis.subscribe('b', (...args) => { - resolve(Array.from(args)); - }); - miis.dispatch('b', 2, 3, 4); + test('resetScope', () => { + miis.setScope('test'); + miis.resetScope(); + expect(miis.getScope()).toBe('default'); + }); + + test('dispatch with different scope', () => { + let count = 0; + + miis.subscribe('a', () => count++); + miis.setScope('test'); + miis.dispatch('a'); + expect(count).toBe(0); + + miis.resetScope(); + miis.dispatch('a'); + expect(count).toBe(1); }); - expect(result).toEqual([2, 3, 4]); - }); }); -describe('*', () => { - test('subscribe', () => { - let count = 0; - miis.subscribe('a', () => count++); - miis.subscribe('*', () => count++); - miis.dispatch('a'); - expect(count).toBe(2); - }); +describe('clear', () => { + test('clear specific event', () => { + let count = 0; + miis.subscribe('a', () => count++); + miis.subscribe('b', () => count++); + miis.clear('a'); + miis.dispatch('a'); + miis.dispatch('b'); + expect(count).toBe(1); + }); + + test('clear all events', () => { + let count = 0; + miis.subscribe('a', () => count++); + miis.subscribe('b', () => count++); + miis.clear(); + miis.dispatch('a'); + miis.dispatch('b'); + expect(count).toBe(0); + }); }); -test('scope', () => { - let count = 0; - miis.subscribe('Event A', () => count++, { scope: 'Scope A' }); - miis.subscribe('Event B', () => count++); - miis.dispatch('Event A'); - expect(count).toBe(0); - miis.setScope('Scope A'); - expect(miis.getScope()).toBe('Scope A'); - miis.dispatch('Event A'); - expect(count).toBe(1); - miis.dispatch('Event B'); - expect(count).toBe(1); - miis.resetScope(); - miis.dispatch('Event B'); - expect(count).toBe(2); +describe('symbol event', () => { + test('symbol as event name', () => { + const sym = Symbol('test'); + let called = false; + miis.subscribe(sym, () => (called = true)); + miis.dispatch(sym); + expect(called).toBe(true); + }); });