diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..181b5ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22, 24, 26] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm test + - run: npm pack --dry-run diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7d2b768 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +Thanks for helping improve `hy-event-store`. + +1. Search existing issues before opening a new one. +2. For bug reports, include the package version and a minimal reproduction. +3. Keep pull requests focused and add or update tests for behavior changes. +4. Run `npm test` before submitting a pull request. + +Security vulnerabilities should be reported through the process in +[`SECURITY.md`](SECURITY.md), not through a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb35e5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present coderwhy + +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 99afd38..37e34d9 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,32 @@ # hy-event-store -An event-based global state management tool for vue, react, mini-program, etc. - -一个基于事件的全局状态管理工具,可以在Vue、React、小程序等任何地方使用。 - +[![npm version](https://img.shields.io/npm/v/hy-event-store.svg)](https://www.npmjs.com/package/hy-event-store) +[![npm downloads](https://img.shields.io/npm/dm/hy-event-store.svg)](https://www.npmjs.com/package/hy-event-store) +[![CI](https://github.com/coderwhy/hy-event-store/actions/workflows/ci.yml/badge.svg)](https://github.com/coderwhy/hy-event-store/actions/workflows/ci.yml) +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +A small, zero-dependency event bus and global state store for Vue, React, +Mini Programs, and vanilla JavaScript. +一个基于事件的全局状态管理工具,可以在Vue、React、小程序等任何地方使用。 -# 设计灵感 +## Why this project 在项目中找到一个更加方便快捷的数据共享方案: -* 后续会完善文档和增加更多好用功能; -* 欢迎star、issue、pull requests,会进行更多改变; +- 同一套 API 可以用于不同前端框架和小程序; +- 核心包零运行时依赖,便于审查和集成; +- 欢迎通过 Issue 和 Pull Request 改进正确性、兼容性与文档。 +## Install - -# 如何使用呢? - -## 1、npm安装依赖 +安装 npm 依赖: ```shell npm install hy-event-store ``` - - -## 2、事件总线(event-bus) +## Event bus / 事件总线 ```js const { HYEventBus } = require('hy-event-store') @@ -38,7 +38,7 @@ const whyCallback1 = (...payload) => { } const whyCallback2 = (...payload) => { - console.log("whyCallback1:", payload) + console.log("whyCallback2:", payload) } const lileiCallback1 = (...payload) => { @@ -68,11 +68,9 @@ setTimeout(() => { }, 3000); ``` +The event bus also provides `once`, `off`, `clear`, and `hasEvent`. - - - -## 3、数据共享(event-store) +## Shared state / 数据共享 ```js const { HYEventStore } = require("hy-event-store") @@ -86,15 +84,14 @@ const eventStore = new HYEventStore({ recommends: [] }, actions: { - getHomeMultidata(ctx) { - console.log(ctx) - axios.get("http://123.207.32.32:8000/home/multidata").then(res => { - const banner = res.data.data.banner - const recommend = res.data.data.recommend - // 赋值 - ctx.banners = banner - ctx.recommends = recommend - }) + async getHomeMultidata(ctx) { + const res = await axios.get("https://example.com/home/multidata") + const banner = res.data.data.banner + const recommend = res.data.data.recommend + // 赋值 + ctx.banners = banner + ctx.recommends = recommend + return { banner, recommend } } } }) @@ -125,5 +122,19 @@ setTimeout(() => { eventStore.dispatch("getHomeMultidata") ``` +`dispatch` returns the action's result, so asynchronous actions can be awaited: + +```js +const result = await eventStore.dispatch("getHomeMultidata") +``` + +State keys must be declared in the initial `state` object. Calling `setState` +with an unknown key throws an error instead of creating an unobservable value. +## Maintenance and security +- Run the regression suite with `npm test`. +- Run the source coverage report with `npm run test:coverage`. +- See [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a pull request. +- Report suspected vulnerabilities through [`SECURITY.md`](SECURITY.md), not a public issue. +- Released under the [MIT License](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ac7a5ad --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,38 @@ +# Roadmap + +`hy-event-store` is returning to active maintenance through small, reviewable +changes. The immediate goal is to improve correctness and project health while +keeping the public API compatible. + +## v1.4.0 — stability and maintenance + +- Fix `clear`, `hasEvent`, and safe listener removal in the event bus + ([#30](https://github.com/coderwhy/hy-event-store/issues/30), + [#33](https://github.com/coderwhy/hy-event-store/issues/33)). +- Return synchronous and asynchronous action results from `dispatch` + ([#29](https://github.com/coderwhy/hy-event-store/issues/29), + [#31](https://github.com/coderwhy/hy-event-store/issues/31), + [#32](https://github.com/coderwhy/hy-event-store/pull/32)). +- Add automated regression tests and a supported Node.js CI matrix. +- Improve package metadata, security reporting, contribution guidance, and API + documentation. + +## After v1.4.0 + +These items need API design or compatibility research before implementation: + +- Add first-party TypeScript declarations + ([#25](https://github.com/coderwhy/hy-event-store/issues/25)). +- Document and test CommonJS, ESM, and Mini Program import paths + ([#27](https://github.com/coderwhy/hy-event-store/issues/27)). +- Evaluate a read-only state getter + ([#26](https://github.com/coderwhy/hy-event-store/issues/26)). +- Explore an opt-in full snapshot for `onStates` + ([#24](https://github.com/coderwhy/hy-event-store/issues/24)). + +## Maintenance principles + +- Prefer focused pull requests with regression tests. +- Preserve backward compatibility unless a major release is justified. +- Document behavior changes and migration steps in release notes. +- Treat Issue and Pull Request triage as part of project maintenance. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c974816 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +The latest published release is the supported version of `hy-event-store`. + +Please do not open a public issue for a suspected vulnerability. Send a report +to `coderwhy@gmail.com` with the subject `[hy-event-store security]`. Include +the affected version, impact, reproduction steps, and any suggested fix. Do +not include credentials, tokens, or unrelated personal data. + +We will acknowledge a complete report, investigate it, and coordinate a fix +and disclosure when the issue is confirmed. diff --git a/Test/event-bus.test.js b/Test/event-bus.test.js new file mode 100644 index 0000000..e116f54 --- /dev/null +++ b/Test/event-bus.test.js @@ -0,0 +1,84 @@ +const test = require("node:test") +const assert = require("node:assert/strict") + +const { HYEventBus } = require("../src") + +test("on and emit deliver every payload with the configured context", () => { + const bus = new HYEventBus() + const calls = [] + const context = { name: "context" } + + assert.equal(bus.on("update", function(...payload) { + calls.push([this, ...payload]) + }, context), bus) + assert.equal(bus.emit("update", "name", 42), bus) + + assert.deepEqual(calls, [[context, "name", 42]]) +}) + +test("once does not skip the next handler", () => { + const bus = new HYEventBus() + const calls = [] + + bus.once("update", () => calls.push("once")) + bus.on("update", () => calls.push("always")) + + bus.emit("update") + bus.emit("update") + + assert.deepEqual(calls, ["once", "always", "always"]) +}) + +test("off is safe for missing events and removes matching handlers", () => { + const bus = new HYEventBus() + const callback = () => {} + let survivorCalls = 0 + const survivor = () => survivorCalls++ + + assert.equal(bus.off("missing", callback), bus) + bus.on("update", callback) + bus.on("update", survivor) + assert.equal(bus.hasEvent("update"), true) + bus.off("update", callback) + bus.emit("update") + assert.equal(survivorCalls, 1) + bus.off("update", survivor) + assert.equal(bus.hasEvent("update"), false) +}) + +test("clear removes every registered event", () => { + const bus = new HYEventBus() + + bus.on("one", () => {}) + bus.on("two", () => {}) + assert.equal(bus.clear(), bus) + assert.equal(bus.hasEvent("one"), false) + assert.equal(bus.hasEvent("two"), false) +}) + +test("event names cannot collide with object prototype properties", () => { + const bus = new HYEventBus() + const calls = [] + + for (const eventName of ["constructor", "toString", "__proto__"]) { + bus.on(eventName, () => calls.push(eventName)) + bus.emit(eventName) + } + + assert.deepEqual(calls, ["constructor", "toString", "__proto__"]) +}) + +test("public methods validate event names and callbacks", () => { + const bus = new HYEventBus() + const invalidName = 42 + const invalidCallback = "callback" + + assert.throws(() => bus.on(invalidName, () => {}), /event name/) + assert.throws(() => bus.on("event", invalidCallback), /event callback/) + assert.throws(() => bus.once(invalidName, () => {}), /event name/) + assert.throws(() => bus.once("event", invalidCallback), /event callback/) + assert.throws(() => bus.emit(invalidName), /event name/) + assert.throws(() => bus.off(invalidName, () => {}), /event name/) + assert.throws(() => bus.off("event", invalidCallback), /event callback/) + assert.throws(() => bus.hasEvent(invalidName), /event name/) +}) diff --git a/Test/event-store.test.js b/Test/event-store.test.js new file mode 100644 index 0000000..7b85e8d --- /dev/null +++ b/Test/event-store.test.js @@ -0,0 +1,121 @@ +const test = require("node:test") +const assert = require("node:assert/strict") + +const { HYEventStore } = require("../src") + +test("constructor validates options, state, and actions", () => { + assert.throws(() => new HYEventStore(), /options must be object/) + assert.throws(() => new HYEventStore({}), /state must be object/) + assert.throws( + () => new HYEventStore({ state: {}, actions: "load" }), + /actions must be object/ + ) + assert.throws( + () => new HYEventStore({ state: {}, actions: { load: true } }), + /value of actions must be a function/ + ) +}) + +test("onState receives initial and changed values until removed", () => { + const store = new HYEventStore({ state: { count: 0 } }) + const values = [] + const callback = value => values.push(value) + + store.onState("count", callback) + store.setState("count", 1) + store.setState("count", 1) + store.offState("count", callback) + store.setState("count", 2) + + assert.deepEqual(values, [0, 1]) +}) + +test("onStates reports the initial snapshot and changed key until removed", () => { + const store = new HYEventStore({ state: { name: "why", age: 18 } }) + const values = [] + const callback = value => values.push(value) + + store.onStates(["name", "age"], callback) + store.setState("age", 19) + store.offStates(["name", "age"], callback) + store.setState("name", "coderwhy") + + assert.deepEqual(values, [ + { name: "why", age: 18 }, + { age: 19 } + ]) +}) + +test("dispatch returns synchronous action results", () => { + const store = new HYEventStore({ + state: { count: 1 }, + actions: { + increment(state, amount) { + state.count += amount + return state.count + } + } + }) + + assert.equal(store.dispatch("increment", 2), 3) +}) + +test("dispatch returns asynchronous action results", async () => { + const store = new HYEventStore({ + state: { status: "idle" }, + actions: { + async load(state) { + await Promise.resolve() + state.status = "ready" + return state.status + } + } + }) + + assert.equal(await store.dispatch("load"), "ready") +}) + +test("state subscriptions validate keys and callback inputs", () => { + const store = new HYEventStore({ state: { count: 0 } }) + const callback = () => {} + + assert.throws(() => store.onState("missing", callback), /does not contain/) + assert.throws(() => store.onState("count", "callback"), /callback/) + assert.throws(() => store.onStates("count", callback), /keys must be array/) + assert.throws(() => store.onStates(["count"], "callback"), /callback/) + assert.throws(() => store.onStates(["missing"], callback), /does not contain/) + assert.throws(() => store.offState("missing", callback), /does not contain/) + assert.throws(() => store.offStates("count", callback), /keys must be array/) + assert.throws(() => store.offStates(["count"], "callback"), /callback/) + assert.throws(() => store.offStates(["missing"], callback), /does not contain/) + assert.throws(() => store.setState("missing", 1), /does not contain/) +}) + +test("multi-state subscriptions validate every key before changing listeners", () => { + const store = new HYEventStore({ state: { count: 0, status: "idle" } }) + const values = [] + const callback = value => values.push(value) + + assert.throws( + () => store.onStates(["count", "missing"], callback), + /does not contain/ + ) + store.setState("count", 1) + assert.deepEqual(values, []) + + store.onStates(["count", "status"], callback) + values.length = 0 + assert.throws( + () => store.offStates(["count", "missing"], callback), + /does not contain/ + ) + store.setState("count", 2) + assert.deepEqual(values, [{ count: 2 }]) +}) + +test("dispatch validates action names and missing actions", () => { + const store = new HYEventStore({ state: {} }) + + assert.throws(() => store.dispatch(42), /action name must be string/) + assert.throws(() => store.dispatch("missing"), /action name does not exist/) +}) diff --git a/package-lock.json b/package-lock.json index 534d429..ceec679 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,13 @@ { "name": "hy-event-store", - "version": "1.0.0", - "lockfileVersion": 1, + "version": "1.3.1", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", - "dev": true, - "requires": { - "follow-redirects": "^1.14.4" - } - }, - "follow-redirects": { - "version": "1.14.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", - "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", - "dev": true + "packages": { + "": { + "name": "hy-event-store", + "version": "1.3.1", + "license": "MIT" } } } diff --git a/package.json b/package.json index db170c7..73740c3 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,30 @@ { "name": "hy-event-store", "version": "1.3.1", - "description": "An event-based global state management tool for vue, react, mini-program, ect.", + "description": "A zero-dependency event bus and global state store for Vue, React, Mini Programs, and vanilla JavaScript.", "main": "src/index.js", + "files": [ + "src" + ], "scripts": { - "test-bus": "node ./Test/event-bus-test.js", - "test-store": "node ./Test/event-store-test.js" + "test": "node --test Test/*.test.js", + "test:coverage": "node --test --experimental-test-coverage Test/*.test.js" }, "author": "coderwhy", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/coderwhy/hy-event-store.git" + }, + "bugs": { + "url": "https://github.com/coderwhy/hy-event-store/issues" + }, + "homepage": "https://github.com/coderwhy/hy-event-store#readme", "keywords": [ - "state managment", + "state-management", + "event-bus", "mini-program", "vue", "react" - ], - "devDependencies": { - "axios": "^0.24.0" - } + ] } diff --git a/src/event-bus.js b/src/event-bus.js index efc8c0d..6a09670 100644 --- a/src/event-bus.js +++ b/src/event-bus.js @@ -1,6 +1,6 @@ class HYEventBus { constructor() { - this.eventBus = {} + this.eventBus = Object.create(null) } on(eventName, eventCallback, thisArg) { @@ -48,7 +48,8 @@ class HYEventBus { } const handlers = this.eventBus[eventName] || [] - handlers.forEach(handler => { + // Use a snapshot so handlers can safely remove themselves while emitting. + handlers.slice().forEach(handler => { handler.eventCallback.apply(handler.thisArg, payload) }) return this @@ -64,28 +65,32 @@ class HYEventBus { } const handlers = this.eventBus[eventName] - if (handlers && eventCallback) { - const newHandlers = [...handlers] - for (let i = 0; i < newHandlers.length; i++) { - const handler = newHandlers[i] - if (handler.eventCallback === eventCallback) { - const index = handlers.indexOf(handler) - handlers.splice(index, 1) - } - } + if (!handlers) { + return this } - if (handlers.length === 0) { + this.eventBus[eventName] = handlers.filter(handler => { + return handler.eventCallback !== eventCallback + }) + + if (this.eventBus[eventName].length === 0) { delete this.eventBus[eventName] } + return this } clear() { - this.emitBus = {} + this.eventBus = Object.create(null) + return this } hasEvent(eventName) { - return Object.keys(this.emitBus).includes(eventName) + if (typeof eventName !== "string") { + throw new TypeError("the event name must be string type") + } + + const handlers = this.eventBus[eventName] + return Boolean(handlers && handlers.length) } } diff --git a/src/event-store.js b/src/event-store.js index ec05a54..be0822f 100644 --- a/src/event-store.js +++ b/src/event-store.js @@ -3,10 +3,18 @@ const { isObject } = require('./utils') class HYEventStore { constructor(options) { + if (!isObject(options)) { + throw new TypeError("the options must be object type") + } if (!isObject(options.state)) { throw new TypeError("the state must be object type") } - if (options.actions && isObject(options.actions)) { + + this.actions = {} + if (options.actions !== undefined) { + if (!isObject(options.actions)) { + throw new TypeError("the actions must be object type") + } const values = Object.values(options.actions) for (const value of values) { if (typeof value !== "function") { @@ -44,12 +52,11 @@ class HYEventStore { if (keys.indexOf(stateKey) === -1) { throw new Error("the state does not contain your key") } - this.event.on(stateKey, stateCallback) - - // callback if (typeof stateCallback !== "function") { throw new TypeError("the event callback must be function type") } + + this.event.on(stateKey, stateCallback) const value = this.state[stateKey] stateCallback.apply(this.state, [value]) } @@ -58,12 +65,22 @@ class HYEventStore { // ["name", "height"] callback2 onStates(statekeys, stateCallback) { + if (!Array.isArray(statekeys)) { + throw new TypeError("the state keys must be array type") + } + if (typeof stateCallback !== "function") { + throw new TypeError("the event callback must be function type") + } + const keys = Object.keys(this.state) - const value = {} for (const theKey of statekeys) { if (keys.indexOf(theKey) === -1) { throw new Error("the state does not contain your key") } + } + + const value = {} + for (const theKey of statekeys) { this.eventV2.on(theKey, stateCallback) value[theKey] = this.state[theKey] } @@ -72,11 +89,21 @@ class HYEventStore { } offStates(stateKeys, stateCallback) { + if (!Array.isArray(stateKeys)) { + throw new TypeError("the state keys must be array type") + } + if (typeof stateCallback !== "function") { + throw new TypeError("the event callback must be function type") + } + const keys = Object.keys(this.state) - stateKeys.forEach(theKey => { + for (const theKey of stateKeys) { if (keys.indexOf(theKey) === -1) { throw new Error("the state does not contain your key") } + } + + stateKeys.forEach(theKey => { this.eventV2.off(theKey, stateCallback) }) } @@ -90,6 +117,9 @@ class HYEventStore { } setState(stateKey, stateValue) { + if (Object.keys(this.state).indexOf(stateKey) === -1) { + throw new Error("the state does not contain your key") + } this.state[stateKey] = stateValue } @@ -101,7 +131,7 @@ class HYEventStore { throw new Error("this action name does not exist, please check it") } const actionFn = this.actions[actionName] - actionFn.apply(this, [this.state, ...args]) + return actionFn.apply(this, [this.state, ...args]) } }