From 53c9dad18a612c97cacc3c0fe5eeb01e6ffcc026 Mon Sep 17 00:00:00 2001 From: Asko Nõmm Date: Mon, 14 Apr 2025 00:34:41 +0300 Subject: Rename EventX to ShapeX The project is renamed from EventX to ShapeX, updating all references in code, documentation and package metadata. --- README.md | 20 ++--- package.json | 12 +-- src/eventx.test.ts | 211 ----------------------------------------------- src/eventx.ts | 234 ----------------------------------------------------- src/shapex.test.ts | 211 +++++++++++++++++++++++++++++++++++++++++++++++ src/shapex.ts | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++++ tsup.config.ts | 2 +- 7 files changed, 462 insertions(+), 462 deletions(-) delete mode 100644 src/eventx.test.ts delete mode 100644 src/eventx.ts create mode 100644 src/shapex.test.ts create mode 100644 src/shapex.ts diff --git a/README.md b/README.md index 64c1942..3838ad8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ -# EventX +# ShapeX -Create scaleable event-driven applications with EventX, inspired by [re-frame](https://github.com/day8/re-frame/). EventX uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really anywhere where JavaScript runs. +Create scalable event-driven applications with ShapeX, inspired by [re-frame](https://github.com/day8/re-frame/). ShapeX uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really anywhere where JavaScript runs. ## Example application -This is an example application that demonstrates how to use the EventX library. It has a single starting point event called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber for the `counter` state fires. +This is an example application that demonstrates how to use the ShapeX library. It has a single starting point event called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber for the `counter` state fires. ```typescript -import EventX from "@askonmm/eventx"; +import ShapeX from "ShapeX"; type AppState = { counter: number; }; -const $ = EventX({ +const $ = ShapeX({ counter: 1, }); @@ -41,23 +41,23 @@ $.dispatch("request"); ## Installation ```shell -npm i @askonmm/eventx +npm i shapex ``` ## Documentation ### State -At the core of your application is state. You start by initiating EventX with some initial state, like so: +At the core of your application is state. You start by initiating ShapeX with some initial state, like so: ```typescript -import EventX from "@askonmm/eventx"; +import ShapeX from "shapex"; type AppState = { counter: number; }; -const $ = EventX({ +const $ = ShapeX({ counter: 1, }); ``` @@ -109,7 +109,7 @@ $.subscribe("$counter", (state) => { }); ``` -Notable difference here is the `$` prefix in the subscription listener name, which tells EventX what state to look for. Here `$counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`) followed by the key name, i.e: `$counter.nestedKey`. Additionally, state change subscriptions do not get any additional data passed to them, only state. +Notable difference here is the `$` prefix in the subscription listener name, which tells ShapeX what state to look for. Here `$counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`) followed by the key name, i.e: `$counter.nestedKey`. Additionally, state change subscriptions do not get any additional data passed to them, only state. #### Subscribe only once diff --git a/package.json b/package.json index 785e2e8..8e36f70 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "@askonmm/eventx", + "name": "shapex", "version": "1.0.3", "description": "A scalable event-driven application framework.", - "main": "dist/eventx.cjs", - "module": "dist/eventx.js", + "main": "dist/shapex.cjs", + "module": "dist/shapex.js", "type": "module", "license": "MIT", "keywords": [ @@ -13,15 +13,15 @@ "subscriptions" ], "bugs": { - "url": "https://github.com/askonomm/eventx/issues", + "url": "https://github.com/askonomm/shapex/issues", "email": "asko@nmm.ee" }, "repository": { "type": "git", - "url": "git+https://github.com/askonomm/eventx.git" + "url": "git+https://github.com/askonomm/shapex.git" }, "scripts": { - "bundle": "tsup src/eventx.ts", + "bundle": "tsup src/shapex.ts", "test": "vitest", "coverage": "vitest run --coverage" }, diff --git a/src/eventx.test.ts b/src/eventx.test.ts deleted file mode 100644 index bb47449..0000000 --- a/src/eventx.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; -import EventX from "./eventx"; - -describe("EventX", () => { - describe("subscribe", () => { - test("subscribes to an event", () => { - const $ = EventX({ counter: 1 }); - const id = $.subscribe("test-event", (state) => ({ state })); - expect(id).toBe(1); - expect($.subscriptionCount("test-event")).toBe(1); - }); - - test("subscribes to an event once", () => { - const $ = EventX({ counter: 1 }); - const id = $.subscribeOnce("test-event", (state) => ({ state })); - expect(id).toBe(1); - expect($.subscriptionCount("test-event")).toBe(1); - - // Dispatch the event to trigger the one-time subscription - $.dispatch("test-event"); - expect($.subscriptionCount("test-event")).toBe(0); - }); - - test("unsubscribes from an event", () => { - const $ = EventX({ counter: 1 }); - $.subscribe("test-event", (state) => ({ state })); - expect($.subscriptionCount("test-event")).toBe(1); - - $.unsubscribe("test-event"); - expect($.subscriptionCount("test-event")).toBe(0); - }); - }); - - describe("dispatch", () => { - test("dispatches an event without arguments", () => { - const $ = EventX({ counter: 1 }); - const callback = vi.fn((state) => ({ state })); - - $.subscribe("test-event", callback); - $.dispatch("test-event"); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith({ counter: 1 }); - }); - - test("dispatches an event with arguments", () => { - const $ = EventX({ counter: 1 }); - const callback = vi.fn((state, arg1, arg2) => ({ state })); - - $.subscribe("test-event", callback); - $.dispatch("test-event", "arg1-value", "arg2-value"); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value", "arg2-value"); - }); - - test("updates state when event handler returns new state", () => { - const $ = EventX({ counter: 1 }); - const stateChangeSpy = vi.fn((state) => ({ state })); - - $.subscribe("$counter", stateChangeSpy); - $.subscribe("increment", (state) => ({ - state: { ...state, counter: state.counter + 1 }, - })); - - $.dispatch("increment"); - - expect(stateChangeSpy).toHaveBeenCalledTimes(1); - expect(stateChangeSpy).toHaveBeenCalledWith({ counter: 2 }); - }); - - test("dispatches nested events", () => { - const $ = EventX({ counter: 1 }); - const nestedEventSpy = vi.fn((state) => ({ state })); - - $.subscribe("nested-event", nestedEventSpy); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: { eventName: "nested-event" }, - })); - - $.dispatch("parent-event"); - - expect(nestedEventSpy).toHaveBeenCalledTimes(1); - }); - - test("dispatches multiple nested events", () => { - const $ = EventX({ counter: 1 }); - const nestedEvent1Spy = vi.fn((state) => ({ state })); - const nestedEvent2Spy = vi.fn((state) => ({ state })); - - $.subscribe("nested-event-1", nestedEvent1Spy); - $.subscribe("nested-event-2", nestedEvent2Spy); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: [{ eventName: "nested-event-1" }, { eventName: "nested-event-2" }], - })); - - $.dispatch("parent-event"); - - expect(nestedEvent1Spy).toHaveBeenCalledTimes(1); - expect(nestedEvent2Spy).toHaveBeenCalledTimes(1); - }); - - test("dispatches nested events with arguments", () => { - const $ = EventX({ counter: 1 }); - const nestedEventSpy = vi.fn((state, arg) => ({ state })); - - $.subscribe("nested-event", nestedEventSpy); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: { eventName: "nested-event", args: ["arg-value"] }, - })); - - $.dispatch("parent-event"); - - expect(nestedEventSpy).toHaveBeenCalledTimes(1); - expect(nestedEventSpy).toHaveBeenCalledWith({ counter: 1 }, "arg-value"); - }); - }); - - describe("state change detection", () => { - test("detects value changes in state", () => { - const $ = EventX({ counter: 1, nested: { value: "test" } }); - const counterChangeSpy = vi.fn((state) => ({ state })); - - $.subscribe("$counter", counterChangeSpy); - $.subscribe("change-counter", (state) => ({ - state: { ...state, counter: 2 }, - })); - - $.dispatch("change-counter"); - - expect(counterChangeSpy).toHaveBeenCalledTimes(1); - expect(counterChangeSpy).toHaveBeenCalledWith({ counter: 2, nested: { value: "test" } }); - }); - - test("detects nested value changes in state", () => { - const $ = EventX({ counter: 1, nested: { value: "test" } }); - const nestedValueChangeSpy = vi.fn((state) => ({ state })); - - $.subscribe("$nested.value", nestedValueChangeSpy); - $.subscribe("change-nested-value", (state) => ({ - state: { - ...state, - nested: { ...state.nested, value: "changed" }, - }, - })); - - $.dispatch("change-nested-value"); - - expect(nestedValueChangeSpy).toHaveBeenCalledTimes(1); - expect(nestedValueChangeSpy).toHaveBeenCalledWith({ - counter: 1, - nested: { value: "changed" }, - }); - }); - - test("detects deleted properties in state", () => { - const $ = EventX({ counter: 1, toDelete: "value" } as { counter: number; toDelete?: string }); - const deleteChangeSpy = vi.fn((state) => ({ state })); - - $.subscribe("$toDelete", deleteChangeSpy); - $.subscribe("delete-property", (state) => { - const newState = { counter: state.counter }; - return { state: newState }; - }); - - $.dispatch("delete-property"); - - expect(deleteChangeSpy).toHaveBeenCalledTimes(1); - }); - - test("detects type changes in state", () => { - const $ = EventX({ counter: 1 } as { counter: string | number }); - const counterChangeSpy = vi.fn((state) => ({ state })); - - $.subscribe("$counter", counterChangeSpy); - $.subscribe("change-counter-type", (state) => ({ - state: { ...state, counter: "string now" }, - })); - - $.dispatch("change-counter-type"); - - expect(counterChangeSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe("utility methods", () => { - test("returns all subscription names", () => { - const $ = EventX({ counter: 1 }); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event2", (state) => ({ state })); - - const subs = $.subscriptions(); - expect(subs).toContain("event1"); - expect(subs).toContain("event2"); - expect(subs.length).toBe(2); - }); - - test("returns subscription count for specific event", () => { - const $ = EventX({ counter: 1 }); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event2", (state) => ({ state })); - - expect($.subscriptionCount("event1")).toBe(2); - expect($.subscriptionCount("event2")).toBe(1); - }); - }); -}); diff --git a/src/eventx.ts b/src/eventx.ts deleted file mode 100644 index 0e108d9..0000000 --- a/src/eventx.ts +++ /dev/null @@ -1,234 +0,0 @@ -export type EventDispatcher = (eventName: string, ...args: unknown[]) => void; - -export type SubscriptionResponseDispatch = { - eventName: string; - args?: unknown[]; -}; - -export type SubscriptionResponse = { - state?: T; - dispatch?: SubscriptionResponseDispatch | SubscriptionResponseDispatch[]; -}; - -const isSubscriptionResponseList = ( - dispatch: SubscriptionResponseDispatch | SubscriptionResponseDispatch[], -): dispatch is SubscriptionResponseDispatch[] => Array.isArray(dispatch); - -export type EventCallback = (state: T, ...args: any[]) => SubscriptionResponse; - -export type Subscription = { - listener: string; - callback: EventCallback; - once: boolean; -}; - -export type StateChange = "deleted" | "changed-type" | "changed-value"; - -export type EventX = { - subscribe: (listener: string, callback: EventCallback) => number; - subscribeOnce: (listener: string, callback: EventCallback) => number; - unsubscribe: (listener: string) => void; - subscriptionCount: (eventName: string) => number; - subscriptions: () => string[]; - dispatch: (eventName: string, ...args: any[]) => void; -}; - -/** - * A function that creates an EventX object. - * - * @param {T extends object} initialState The initial application state. - * @returns {EventX} The EventX object. - */ -const EventX = (initialState: T): EventX => { - let _state = initialState; - const _subscriptions: Map[]> = new Map(); - let subscriptionId = 0; - - /** - * Subcribe to an event. - * - * @param {string} listener - * @param {EventCallback} callback - * @returns - */ - const subscribe = (listener: string, callback: EventCallback): number => { - if (!_subscriptions.has(listener)) { - _subscriptions.set(listener, []); - } - - _subscriptions.get(listener)?.push({ - listener, - callback, - once: false, - }); - - return ++subscriptionId; - }; - - /** - * Subcribe to an event, once. - * - * @param {string} listener - * @param {EventCallback} callback - * @returns - */ - const subscribeOnce = (listener: string, callback: EventCallback): number => { - if (!_subscriptions.has(listener)) { - _subscriptions.set(listener, []); - } - - _subscriptions.get(listener)?.push({ - listener, - callback, - once: true, - }); - - return ++subscriptionId; - }; - - const unsubscribe = (listener: string): void => { - if (_subscriptions.has(listener)) { - _subscriptions.delete(listener); - } - }; - - /** - * Composes a list of changes between two states. - * - * @param {T extends object} oldState - * @param {T extends object} newState - * @param {string} path - * @returns {StateChange[]} The list of changes. - */ - const changedState = ( - oldState: T, - newState: T, - path: string = "", - ): Map => { - let changes: Map = new Map(); - - for (const k in oldState) { - const currentPath = path ? `${path}.${k}` : k; - - // Missing? - if (!(k in newState)) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "deleted"); - } - } - - // Type changed? - if (typeof oldState[k] !== typeof newState[k]) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "changed-type"); - } - } - - // Recursive object check - if ( - typeof oldState[k] === "object" && - typeof newState[k] === "object" && - oldState[k] !== null && - newState[k] !== null - ) { - changedState(oldState[k], newState[k], currentPath).forEach((v, k) => { - changes.set(k, v); - }); - } - - // Value changed? - if (JSON.stringify(oldState[k]) !== JSON.stringify(newState[k])) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "changed-value"); - } - } - } - - return changes; - }; - - /** - * Dispatches an event with the given name and arguments. - * - * @param {string} eventName The name of the event to dispatch. - * @param {unknown[]} args The arguments to pass to the event listeners. - * @returns {void} - */ - const dispatch = (eventName: string, ...args: unknown[]): void => { - if (!_subscriptions.has(eventName)) { - return; - } - - const scopedSubsriptions = _subscriptions.get(eventName) ?? []; - const remainingSubscriptions = []; - let callbackCount = 0; - - for (const subscription of scopedSubsriptions) { - const response = subscription.callback(_state, ...args); - - // Updates state, and checks for state changes, and if any changes present, - // fires a dispatch for all the state listeners (if there are any). - if (typeof response.state !== "undefined") { - const changes = changedState(_state, response.state); - _state = response.state; - - changes.forEach((_, v) => { - dispatch(`\$${v}`); - }); - } - - // Dispatches events - if (response.dispatch) { - if (isSubscriptionResponseList(response.dispatch)) { - for (const dispatchee of response.dispatch) { - dispatch(dispatchee.eventName, ...(dispatchee.args ?? [])); - } - } else { - dispatch(response.dispatch.eventName, ...(response.dispatch.args ?? [])); - } - } - - callbackCount++; - - if (!subscription.once) { - remainingSubscriptions.push(subscription); - } - } - - _subscriptions.set(eventName, remainingSubscriptions); - }; - - /** - * Returns the number of subscriptions for the given event name. - * - * @param {string} eventName The name of the event to check. - * @returns {number} The number of subscriptions for the given event name. - */ - const subscriptionCount = (eventName: string | null): number => { - if (eventName) { - return _subscriptions.get(eventName)?.length ?? 0; - } - - return Array.from(_subscriptions.keys()).length; - }; - - /** - * Returns the names of all subscriptions. - * - * @returns {string[]} An array of subscription names. - */ - const subscriptions = (): string[] => { - return Array.from(_subscriptions.keys()); - }; - - return { - subscribe, - subscribeOnce, - unsubscribe, - subscriptionCount, - subscriptions, - dispatch, - }; -}; - -export default EventX; diff --git a/src/shapex.test.ts b/src/shapex.test.ts new file mode 100644 index 0000000..aadeea7 --- /dev/null +++ b/src/shapex.test.ts @@ -0,0 +1,211 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import ShapeX from "./shapex.ts"; + +describe("EventX", () => { + describe("subscribe", () => { + test("subscribes to an event", () => { + const $ = ShapeX({ counter: 1 }); + const id = $.subscribe("test-event", (state) => ({ state })); + expect(id).toBe(1); + expect($.subscriptionCount("test-event")).toBe(1); + }); + + test("subscribes to an event once", () => { + const $ = ShapeX({ counter: 1 }); + const id = $.subscribeOnce("test-event", (state) => ({ state })); + expect(id).toBe(1); + expect($.subscriptionCount("test-event")).toBe(1); + + // Dispatch the event to trigger the one-time subscription + $.dispatch("test-event"); + expect($.subscriptionCount("test-event")).toBe(0); + }); + + test("unsubscribes from an event", () => { + const $ = ShapeX({ counter: 1 }); + $.subscribe("test-event", (state) => ({ state })); + expect($.subscriptionCount("test-event")).toBe(1); + + $.unsubscribe("test-event"); + expect($.subscriptionCount("test-event")).toBe(0); + }); + }); + + describe("dispatch", () => { + test("dispatches an event without arguments", () => { + const $ = ShapeX({ counter: 1 }); + const callback = vi.fn((state) => ({ state })); + + $.subscribe("test-event", callback); + $.dispatch("test-event"); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ counter: 1 }); + }); + + test("dispatches an event with arguments", () => { + const $ = ShapeX({ counter: 1 }); + const callback = vi.fn((state, arg1, arg2) => ({ state })); + + $.subscribe("test-event", callback); + $.dispatch("test-event", "arg1-value", "arg2-value"); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value", "arg2-value"); + }); + + test("updates state when event handler returns new state", () => { + const $ = ShapeX({ counter: 1 }); + const stateChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$counter", stateChangeSpy); + $.subscribe("increment", (state) => ({ + state: { ...state, counter: state.counter + 1 }, + })); + + $.dispatch("increment"); + + expect(stateChangeSpy).toHaveBeenCalledTimes(1); + expect(stateChangeSpy).toHaveBeenCalledWith({ counter: 2 }); + }); + + test("dispatches nested events", () => { + const $ = ShapeX({ counter: 1 }); + const nestedEventSpy = vi.fn((state) => ({ state })); + + $.subscribe("nested-event", nestedEventSpy); + $.subscribe("parent-event", (state) => ({ + state, + dispatch: { eventName: "nested-event" }, + })); + + $.dispatch("parent-event"); + + expect(nestedEventSpy).toHaveBeenCalledTimes(1); + }); + + test("dispatches multiple nested events", () => { + const $ = ShapeX({ counter: 1 }); + const nestedEvent1Spy = vi.fn((state) => ({ state })); + const nestedEvent2Spy = vi.fn((state) => ({ state })); + + $.subscribe("nested-event-1", nestedEvent1Spy); + $.subscribe("nested-event-2", nestedEvent2Spy); + $.subscribe("parent-event", (state) => ({ + state, + dispatch: [{ eventName: "nested-event-1" }, { eventName: "nested-event-2" }], + })); + + $.dispatch("parent-event"); + + expect(nestedEvent1Spy).toHaveBeenCalledTimes(1); + expect(nestedEvent2Spy).toHaveBeenCalledTimes(1); + }); + + test("dispatches nested events with arguments", () => { + const $ = ShapeX({ counter: 1 }); + const nestedEventSpy = vi.fn((state, arg) => ({ state })); + + $.subscribe("nested-event", nestedEventSpy); + $.subscribe("parent-event", (state) => ({ + state, + dispatch: { eventName: "nested-event", args: ["arg-value"] }, + })); + + $.dispatch("parent-event"); + + expect(nestedEventSpy).toHaveBeenCalledTimes(1); + expect(nestedEventSpy).toHaveBeenCalledWith({ counter: 1 }, "arg-value"); + }); + }); + + describe("state change detection", () => { + test("detects value changes in state", () => { + const $ = ShapeX({ counter: 1, nested: { value: "test" } }); + const counterChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$counter", counterChangeSpy); + $.subscribe("change-counter", (state) => ({ + state: { ...state, counter: 2 }, + })); + + $.dispatch("change-counter"); + + expect(counterChangeSpy).toHaveBeenCalledTimes(1); + expect(counterChangeSpy).toHaveBeenCalledWith({ counter: 2, nested: { value: "test" } }); + }); + + test("detects nested value changes in state", () => { + const $ = ShapeX({ counter: 1, nested: { value: "test" } }); + const nestedValueChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$nested.value", nestedValueChangeSpy); + $.subscribe("change-nested-value", (state) => ({ + state: { + ...state, + nested: { ...state.nested, value: "changed" }, + }, + })); + + $.dispatch("change-nested-value"); + + expect(nestedValueChangeSpy).toHaveBeenCalledTimes(1); + expect(nestedValueChangeSpy).toHaveBeenCalledWith({ + counter: 1, + nested: { value: "changed" }, + }); + }); + + test("detects deleted properties in state", () => { + const $ = ShapeX({ counter: 1, toDelete: "value" } as { counter: number; toDelete?: string }); + const deleteChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$toDelete", deleteChangeSpy); + $.subscribe("delete-property", (state) => { + const newState = { counter: state.counter }; + return { state: newState }; + }); + + $.dispatch("delete-property"); + + expect(deleteChangeSpy).toHaveBeenCalledTimes(1); + }); + + test("detects type changes in state", () => { + const $ = ShapeX({ counter: 1 } as { counter: string | number }); + const counterChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$counter", counterChangeSpy); + $.subscribe("change-counter-type", (state) => ({ + state: { ...state, counter: "string now" }, + })); + + $.dispatch("change-counter-type"); + + expect(counterChangeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("utility methods", () => { + test("returns all subscription names", () => { + const $ = ShapeX({ counter: 1 }); + $.subscribe("event1", (state) => ({ state })); + $.subscribe("event2", (state) => ({ state })); + + const subs = $.subscriptions(); + expect(subs).toContain("event1"); + expect(subs).toContain("event2"); + expect(subs.length).toBe(2); + }); + + test("returns subscription count for specific event", () => { + const $ = ShapeX({ counter: 1 }); + $.subscribe("event1", (state) => ({ state })); + $.subscribe("event1", (state) => ({ state })); + $.subscribe("event2", (state) => ({ state })); + + expect($.subscriptionCount("event1")).toBe(2); + expect($.subscriptionCount("event2")).toBe(1); + }); + }); +}); diff --git a/src/shapex.ts b/src/shapex.ts new file mode 100644 index 0000000..93f40e7 --- /dev/null +++ b/src/shapex.ts @@ -0,0 +1,234 @@ +export type EventDispatcher = (eventName: string, ...args: unknown[]) => void; + +export type SubscriptionResponseDispatch = { + eventName: string; + args?: unknown[]; +}; + +export type SubscriptionResponse = { + state?: T; + dispatch?: SubscriptionResponseDispatch | SubscriptionResponseDispatch[]; +}; + +const isSubscriptionResponseList = ( + dispatch: SubscriptionResponseDispatch | SubscriptionResponseDispatch[], +): dispatch is SubscriptionResponseDispatch[] => Array.isArray(dispatch); + +export type EventCallback = (state: T, ...args: any[]) => SubscriptionResponse; + +export type Subscription = { + listener: string; + callback: EventCallback; + once: boolean; +}; + +export type StateChange = "deleted" | "changed-type" | "changed-value"; + +export type ShapeX = { + subscribe: (listener: string, callback: EventCallback) => number; + subscribeOnce: (listener: string, callback: EventCallback) => number; + unsubscribe: (listener: string) => void; + subscriptionCount: (eventName: string) => number; + subscriptions: () => string[]; + dispatch: (eventName: string, ...args: any[]) => void; +}; + +/** + * A function that creates an EventX object. + * + * @param {T extends object} initialState The initial application state. + * @returns {EventX} The EventX object. + */ +const ShapeX = (initialState: T): ShapeX => { + let _state = initialState; + const _subscriptions: Map[]> = new Map(); + let subscriptionId = 0; + + /** + * Subcribe to an event. + * + * @param {string} listener + * @param {EventCallback} callback + * @returns + */ + const subscribe = (listener: string, callback: EventCallback): number => { + if (!_subscriptions.has(listener)) { + _subscriptions.set(listener, []); + } + + _subscriptions.get(listener)?.push({ + listener, + callback, + once: false, + }); + + return ++subscriptionId; + }; + + /** + * Subcribe to an event, once. + * + * @param {string} listener + * @param {EventCallback} callback + * @returns + */ + const subscribeOnce = (listener: string, callback: EventCallback): number => { + if (!_subscriptions.has(listener)) { + _subscriptions.set(listener, []); + } + + _subscriptions.get(listener)?.push({ + listener, + callback, + once: true, + }); + + return ++subscriptionId; + }; + + const unsubscribe = (listener: string): void => { + if (_subscriptions.has(listener)) { + _subscriptions.delete(listener); + } + }; + + /** + * Composes a list of changes between two states. + * + * @param {T extends object} oldState + * @param {T extends object} newState + * @param {string} path + * @returns {StateChange[]} The list of changes. + */ + const changedState = ( + oldState: T, + newState: T, + path: string = "", + ): Map => { + let changes: Map = new Map(); + + for (const k in oldState) { + const currentPath = path ? `${path}.${k}` : k; + + // Missing? + if (!(k in newState)) { + if (!changes.has(currentPath)) { + changes.set(currentPath, "deleted"); + } + } + + // Type changed? + if (typeof oldState[k] !== typeof newState[k]) { + if (!changes.has(currentPath)) { + changes.set(currentPath, "changed-type"); + } + } + + // Recursive object check + if ( + typeof oldState[k] === "object" && + typeof newState[k] === "object" && + oldState[k] !== null && + newState[k] !== null + ) { + changedState(oldState[k], newState[k], currentPath).forEach((v, k) => { + changes.set(k, v); + }); + } + + // Value changed? + if (JSON.stringify(oldState[k]) !== JSON.stringify(newState[k])) { + if (!changes.has(currentPath)) { + changes.set(currentPath, "changed-value"); + } + } + } + + return changes; + }; + + /** + * Dispatches an event with the given name and arguments. + * + * @param {string} eventName The name of the event to dispatch. + * @param {unknown[]} args The arguments to pass to the event listeners. + * @returns {void} + */ + const dispatch = (eventName: string, ...args: unknown[]): void => { + if (!_subscriptions.has(eventName)) { + return; + } + + const scopedSubsriptions = _subscriptions.get(eventName) ?? []; + const remainingSubscriptions = []; + let callbackCount = 0; + + for (const subscription of scopedSubsriptions) { + const response = subscription.callback(_state, ...args); + + // Updates state, and checks for state changes, and if any changes present, + // fires a dispatch for all the state listeners (if there are any). + if (typeof response.state !== "undefined") { + const changes = changedState(_state, response.state); + _state = response.state; + + changes.forEach((_, v) => { + dispatch(`\$${v}`); + }); + } + + // Dispatches events + if (response.dispatch) { + if (isSubscriptionResponseList(response.dispatch)) { + for (const dispatchee of response.dispatch) { + dispatch(dispatchee.eventName, ...(dispatchee.args ?? [])); + } + } else { + dispatch(response.dispatch.eventName, ...(response.dispatch.args ?? [])); + } + } + + callbackCount++; + + if (!subscription.once) { + remainingSubscriptions.push(subscription); + } + } + + _subscriptions.set(eventName, remainingSubscriptions); + }; + + /** + * Returns the number of subscriptions for the given event name. + * + * @param {string} eventName The name of the event to check. + * @returns {number} The number of subscriptions for the given event name. + */ + const subscriptionCount = (eventName: string | null): number => { + if (eventName) { + return _subscriptions.get(eventName)?.length ?? 0; + } + + return Array.from(_subscriptions.keys()).length; + }; + + /** + * Returns the names of all subscriptions. + * + * @returns {string[]} An array of subscription names. + */ + const subscriptions = (): string[] => { + return Array.from(_subscriptions.keys()); + }; + + return { + subscribe, + subscribeOnce, + unsubscribe, + subscriptionCount, + subscriptions, + dispatch, + }; +}; + +export default ShapeX; diff --git a/tsup.config.ts b/tsup.config.ts index 13e76f2..74dcfd7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/eventx.ts"], + entry: ["src/shapex.ts"], clean: true, format: ["esm", "cjs"], dts: true, -- cgit v1.2.3