diff --git a/.gitignore b/.gitignore index 0842e14..afa1bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ node_modules dist *.local -docs/.vitepress/cache \ No newline at end of file +docs/.vitepress/cache +tsconfig.vitest-temp.json diff --git a/package.json b/package.json index cd8f3b0..fa66384 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "homepage": "https://github.com/kitbagjs/events#readme", "scripts": { "build": "tsc && vite build", - "test": "vitest", + "test": "vitest --typecheck", "types": "tsc --noEmit", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", diff --git a/src/createEmitter.ts b/src/createEmitter.ts new file mode 100644 index 0000000..b606740 --- /dev/null +++ b/src/createEmitter.ts @@ -0,0 +1,223 @@ +import { EmitterTimeoutError } from './errors' +import { + EmitterOptions, + EmitterEvents, + EventHandler, + GlobalEventHandler, + EmitterOnOptions, + EmitterOnceOptions, + EmitterNextOptions, + GlobalEventHandlerResponse, + EventPayload, +} from './types' + +type EmitterState = { + channel?: BroadcastChannel | null +} + +export function createEmitter(options?: EmitterOptions) { + type TEvent = keyof TEvents + + const handlers = new Map>() + const globalHandlers = new Set>() + const state: EmitterState = {} + + if(options) { + setOptions(options) + } + + function setOptions(options: EmitterOptions): void { + const channel = getBroadcastChannel(options.broadcastChannel) + + if(channel) { + channel.onmessage = onBroadcastChannelMessage + } + + state.channel = channel + } + + function onBroadcastChannelMessage({data}: MessageEvent) { + const { event, payload } = data + + onEvent(event, payload) + } + + function on(globalEventHandler: GlobalEventHandler, options?: EmitterOnOptions): () => void + function on(event: E, handler: EventHandler>, options?: EmitterOnOptions): () => void + function on(globalHandlerOrEvent: E | GlobalEventHandler, handlerOrOptions?: EventHandler> | EmitterOnOptions, options?: EmitterOnOptions): () => void { + if (isGlobalEventHandler(globalHandlerOrEvent)) { + const globalHandlerOptions = typeof handlerOrOptions === 'object' ? handlerOrOptions : {} + + return addGlobalHandler(globalHandlerOrEvent, globalHandlerOptions) + } + + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined + const event = globalHandlerOrEvent + const handlerOptions = options ?? {} + + if (!handler) { + throw new Error(`Handler must be given for ${String(event)} event`) + } + + return addEventHandler(event, handler, handlerOptions) + } + + function once(globalEventHandler: GlobalEventHandler, options?: EmitterOnceOptions): () => void + function once(event: E, handler: EventHandler>, options?: EmitterOnceOptions): () => void + function once(globalHandlerOrEvent: E | GlobalEventHandler, handlerOrOptions?: EventHandler> | EmitterOnceOptions, options?: EmitterOnceOptions): () => void { + if (isGlobalEventHandler(globalHandlerOrEvent)) { + const globalHandlerOptions = typeof handlerOrOptions === 'object' ? handlerOrOptions : {} + + const callback: GlobalEventHandler = (args) => { + off(callback) + globalHandlerOrEvent(args) + } + + return addGlobalHandler(callback, globalHandlerOptions) + } + + const event = globalHandlerOrEvent + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined + const handlerOptions = options ?? {} + + if (!handler) { + throw new Error(`Handler must be given for ${String(event)} event`) + } + + const callback: EventHandler> = (args) => { + off(event, callback) + handler(args) + } + + return addEventHandler(event, callback, handlerOptions) + } + + function next(options?: EmitterNextOptions): Promise> + function next(event: E, options?: EmitterNextOptions): Promise> + function next(eventOrOptions?: E | EmitterNextOptions, options?: EmitterNextOptions): Promise |EventPayload> { + const event = typeof eventOrOptions === 'string' ? eventOrOptions : undefined + const { timeout } = typeof eventOrOptions === 'object' ? eventOrOptions : options ?? {} + + if(event) { + return new Promise((resolve, reject) => { + once(event, resolve) + + if(timeout) { + setTimeout(() => { + reject(new EmitterTimeoutError(event, timeout)) + }, timeout) + } + }) + } + + return new Promise((resolve, reject) => { + once(resolve) + + if(timeout) { + setTimeout(() => { + reject(new EmitterTimeoutError('global', timeout)) + }, timeout) + } + }) + } + + function off(globalEventHandler: GlobalEventHandler): void + function off(event: E): void + function off(event: E, handler: EventHandler>): void + function off(globalHandlerOrEvent: E, handler?: EventHandler>): void { + if (isGlobalEventHandler(globalHandlerOrEvent)) { + globalHandlers.delete(globalHandlerOrEvent) + return + } + + const event = globalHandlerOrEvent + const eventHandlers = handlers.get(event) + + if (handler) { + eventHandlers?.delete(handler) + return + } + + eventHandlers?.clear() + } + + function emit(event: undefined extends EventPayload ? E : never): void + function emit(event: E, payload: EventPayload): void + function emit(event: E, payload?: EventPayload): void { + state.channel?.postMessage({ event, payload }) + + onEvent(event, payload!) + } + + function clear(): void { + handlers.clear() + globalHandlers.clear() + } + + function isGlobalEventHandler(value: unknown): value is GlobalEventHandler { + return typeof value === 'function' + } + + function onEvent(event: E, payload: EventPayload): void { + handlers.get(event)?.forEach(handler => handler(payload)) + + globalHandlers.forEach(handler => handler({ + kind: event, + payload, + })) + } + + function addGlobalHandler(globalEventHandler: GlobalEventHandler, options?: EmitterOnOptions): () => void { + const { signal } = options ?? {} + + if(signal?.aborted) { + return () => {} + } + + globalHandlers.add(globalEventHandler) + + const offHandler = () => off(globalEventHandler) + + signal?.addEventListener('abort', offHandler) + + return offHandler + } + + function addEventHandler(event: E, handler: EventHandler>, options?: EmitterOnOptions): () => void { + const { signal } = options ?? {} + + if(signal?.aborted) { + return () => {} + } + + if(!handlers.has(event)){ + handlers.set(event, new Set()) + } + + handlers.get(event)?.add(handler) + + const offHandler = () => off(event, handler) + + signal?.addEventListener('abort', offHandler) + + return offHandler + } + + return { + on, + off, + once, + next, + emit, + clear, + setOptions, + } +} + +function getBroadcastChannel(useBroadcastChannel: string = ''): BroadcastChannel | null { + if(useBroadcastChannel) { + return new BroadcastChannel(useBroadcastChannel) + } + + return null +} \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..2a77b21 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,5 @@ +export class EmitterTimeoutError extends Error { + constructor(event: string, timeout: number) { + super(`Timeout waiting for ${event} event after ${timeout}ms`) + } +} diff --git a/src/main.ts b/src/main.ts index b25e43b..294590c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,247 +1,13 @@ -export type EmitterOptions = { - broadcastChannel?: string -} - -type Handler = (...payload: T[]) => void - -type Events = Record - -export type GlobalEventHandler = (event: GlobalEvent) => void - -export type GlobalEvent = { - [K in keyof T]: { - kind: K, - payload: T[K], - } -}[keyof T] - -export type NextOptions = { - timeout?: number -} - -export type EmitterOnOptions = { - signal?: AbortSignal -} - -export type EmitterOnceOptions = { - signal?: AbortSignal -} - -export class EmitterTimeoutError extends Error { - constructor(event: string, timeout: number) { - super(`Timeout waiting for ${event} event after ${timeout}ms`) - } -} - -type EmitterState = { - channel?: BroadcastChannel | null -} - -function getBroadcastChannel(useBroadcastChannel: string = ''): BroadcastChannel | null { - if(useBroadcastChannel) { - return new BroadcastChannel(useBroadcastChannel) - } - - return null -} - -export function createEmitter(options?: EmitterOptions) { - type Event = keyof T - type EventPayload = T[E] - type Handlers = Set - - const handlers = new Map() - const globalHandlers = new Set>() - const state: EmitterState = {} - - if(options) { - setOptions(options) - } - - function setOptions(options: EmitterOptions): void { - const channel = getBroadcastChannel(options.broadcastChannel) - - if(channel) { - channel.onmessage = onBroadcastChannelMessage - } - - state.channel = channel - } - - function onBroadcastChannelMessage({data}: MessageEvent) { - const { event, payload } = data - - onEvent(event, payload) - } - - function on(globalEventHandler: GlobalEventHandler, options?: EmitterOnOptions): () => void - function on(event: E, handler: Handler>, options?: EmitterOnOptions): () => void - function on(globalHandlerOrEvent: E | GlobalEventHandler, handlerOrOptions?: Handler> | EmitterOnOptions, options?: EmitterOnOptions): () => void { - if (isGlobalEventHandler(globalHandlerOrEvent)) { - const globalHandlerOptions = typeof handlerOrOptions === 'object' ? handlerOrOptions : {} - - return addGlobalHandler(globalHandlerOrEvent, globalHandlerOptions) - } - - const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined - const event = globalHandlerOrEvent - const handlerOptions = options ?? {} - - if (!handler) { - throw new Error(`Handler must be given for ${String(event)} event`) - } - - return addEventHandler(event, handler, handlerOptions) - } - - function once(globalEventHandler: GlobalEventHandler, options?: EmitterOnceOptions): () => void - function once(event: E, handler: Handler>, options?: EmitterOnceOptions): () => void - function once(globalHandlerOrEvent: E | GlobalEventHandler, handlerOrOptions?: Handler> | EmitterOnceOptions, options?: EmitterOnceOptions): () => void { - if (isGlobalEventHandler(globalHandlerOrEvent)) { - const globalHandlerOptions = typeof handlerOrOptions === 'object' ? handlerOrOptions : {} - - const callback: GlobalEventHandler = (args) => { - off(callback) - globalHandlerOrEvent(args) - } - - return addGlobalHandler(callback, globalHandlerOptions) - } - - const event = globalHandlerOrEvent - const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined - const handlerOptions = options ?? {} - - if (!handler) { - throw new Error(`Handler must be given for ${String(event)} event`) - } - - const callback: Handler> = (args) => { - off(event, callback) - handler(args) - } - - return addEventHandler(event, callback, handlerOptions) - } - - function next(options?: NextOptions): Promise> - function next(event: E, options?: NextOptions): Promise> - function next(eventOrOptions?: E | NextOptions, options?: NextOptions): Promise |EventPayload> { - const event = typeof eventOrOptions === 'string' ? eventOrOptions : undefined - const { timeout } = typeof eventOrOptions === 'object' ? eventOrOptions : options ?? {} - - if(event) { - return new Promise((resolve, reject) => { - once(event, resolve) - - if(timeout) { - setTimeout(() => { - reject(new EmitterTimeoutError(event, timeout)) - }, timeout) - } - }) - } - - return new Promise((resolve, reject) => { - once(resolve) - - if(timeout) { - setTimeout(() => { - reject(new EmitterTimeoutError('global', timeout)) - }, timeout) - } - }) - } - - function off(globalEventHandler: GlobalEventHandler): void - function off(event: E): void - function off(event: E, handler: Handler>): void - function off(globalHandlerOrEvent: E, handler?: Handler>): void { - if (isGlobalEventHandler(globalHandlerOrEvent)) { - globalHandlers.delete(globalHandlerOrEvent) - return - } - - const event = globalHandlerOrEvent - const eventHandlers = handlers.get(event) - - if (handler) { - eventHandlers?.delete(handler) - return - } - - eventHandlers?.clear() - } - - function emit(event: undefined extends EventPayload ? E : never): void - function emit(event: E, payload: EventPayload): void - function emit(event: E, payload?: EventPayload): void { - state.channel?.postMessage({ event, payload }) - - onEvent(event, payload!) - } - - function clear(): void { - handlers.clear() - globalHandlers.clear() - } - - function isGlobalEventHandler(value: unknown): value is GlobalEventHandler { - return typeof value === 'function' - } - - function onEvent(event: E, payload: EventPayload): void { - handlers.get(event)?.forEach(handler => handler(payload)) - - globalHandlers.forEach(handler => handler({ - kind: event, - payload, - })) - } - - function addGlobalHandler(globalEventHandler: GlobalEventHandler, options?: EmitterOnOptions): () => void { - const { signal } = options ?? {} - - if(signal?.aborted) { - return () => {} - } - - globalHandlers.add(globalEventHandler) - - const offHandler = () => off(globalEventHandler) - - signal?.addEventListener('abort', offHandler) - - return offHandler - } - - function addEventHandler(event: E, handler: Handler>, options?: EmitterOnOptions): () => void { - const { signal } = options ?? {} - - if(signal?.aborted) { - return () => {} - } - - if(!handlers.has(event)){ - handlers.set(event, new Set()) - } - - handlers.get(event)?.add(handler) - - const offHandler = () => off(event, handler) - - signal?.addEventListener('abort', offHandler) - - return offHandler - } - - return { - on, - off, - once, - next, - emit, - clear, - setOptions, - } -} \ No newline at end of file +export type { + EmitterOptions, + EmitterEvents, + EventHandler, + GlobalEventHandler, + EmitterOnOptions, + EmitterOnceOptions, + EmitterNextOptions, + GlobalEventHandlerResponse, +} from './types' + +export { createEmitter } from './createEmitter' +export { EmitterTimeoutError } from './errors' \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cf9131e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,30 @@ +export type EmitterOptions = { + broadcastChannel?: string +} + +export type EmitterNextOptions = { + timeout?: number +} + +export type EmitterOnOptions = { + signal?: AbortSignal +} + +export type EmitterOnceOptions = { + signal?: AbortSignal +} + +export type EventHandler = (...payload: T[]) => void + +export type EventPayload = TEvents[TEvent] + +export type EmitterEvents = Record + +export type GlobalEventHandler = (event: GlobalEventHandlerResponse) => void + +export type GlobalEventHandlerResponse = { + [K in keyof T]: { + kind: K, + payload: T[K], + } +}[keyof T] \ No newline at end of file diff --git a/tests/events.spec-d.ts b/tests/events.spec-d.ts new file mode 100644 index 0000000..7388f55 --- /dev/null +++ b/tests/events.spec-d.ts @@ -0,0 +1,200 @@ +import { describe, expectTypeOf, test, vi } from "vitest"; +import { createEmitter } from "../src/createEmitter"; +import { EmitterNextOptions, EventHandler, EventPayload, GlobalEventHandlerResponse } from "../src/types"; + +type Events = { + ping: void, + hello: string, + user: { name: string } +} + +describe('emitter.on', () => { + test('has the correct return type', () => { + const emitter = createEmitter() + const response = emitter.on('ping', vi.fn()) + + type Source = typeof response + type Expected = () => void + + expectTypeOf().toEqualTypeOf() + + const globalResponse = emitter.on(vi.fn()) + + type GlobalSource = typeof globalResponse + type GlobalExpected = () => void + + expectTypeOf().toEqualTypeOf() + }) + + test('has the correct payload', () => { + const emitter = createEmitter() + + emitter.on('ping', payload => { + expectTypeOf().toEqualTypeOf() + }) + + emitter.on('hello', payload => { + expectTypeOf().toEqualTypeOf() + }) + + emitter.on('user', payload => { + expectTypeOf().toEqualTypeOf<{ name: string }>() + }) + + emitter.on(payload => { + type Expected = GlobalEventHandlerResponse + expectTypeOf().toEqualTypeOf() + }) + }) +}) + +describe('emitter.once', () => { + test('has the correct return type', () => { + const emitter = createEmitter() + const response = emitter.once('hello', vi.fn()) + + type Source = typeof response + type Expected = () => void + + expectTypeOf().toEqualTypeOf() + + const globalResponse = emitter.once(vi.fn()) + + type GlobalSource = typeof globalResponse + type GlobalExpected = () => void + + expectTypeOf().toEqualTypeOf() + }) + + test('has the correct payload', () => { + const emitter = createEmitter() + + emitter.once('ping', payload => { + expectTypeOf().toEqualTypeOf() + }) + + emitter.once('hello', payload => { + expectTypeOf().toEqualTypeOf() + }) + + emitter.once('user', payload => { + expectTypeOf().toEqualTypeOf<{ name: string }>() + }) + + emitter.once(payload => { + type Expected = GlobalEventHandlerResponse + expectTypeOf().toEqualTypeOf() + }) + }) +}) + +describe('emitter.next', () => { + + // note: The `Parameters` type only tests the last overload. So we cannot test the global handler parameters. + test('has the correct arguments', () => { + const emitter = createEmitter() + + type Source = Parameters + type Expected = [event: keyof Events, options?: EmitterNextOptions] + + expectTypeOf().toEqualTypeOf() + }) + + test('has the correct return type', () => { + const emitter = createEmitter() + + const ping = emitter.next('ping') + expectTypeOf().toEqualTypeOf>() + + const hello = emitter.next('hello') + expectTypeOf().toEqualTypeOf>() + + const user = emitter.next('user') + expectTypeOf().toEqualTypeOf>() + + const global = emitter.next() + expectTypeOf().toEqualTypeOf>>() + }) +}) + +describe('emitter.off', () => { + + // note: The `Parameters` type only tests the last overload. So we cannot test the global handler parameters. + test('has the correct arguments', () => { + const emitter = createEmitter() + + type Source = Parameters + type Expected = [event: keyof Events, handler: EventHandler>] + + expectTypeOf().toEqualTypeOf() + }) + + test('has the correct return type', () => { + const emitter = createEmitter() + const eventResponse = emitter.off('hello') + + type Source = typeof eventResponse + type Expected = void + + expectTypeOf().toEqualTypeOf() + + const eventHandlerResponse = emitter.off('hello', vi.fn()) + + type EventHandlerSource = typeof eventHandlerResponse + type EventHandlerExpected = void + + expectTypeOf().toEqualTypeOf() + + const globalResponse = emitter.off(vi.fn()) + + type GlobalSource = typeof globalResponse + type GlobalExpected = void + + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('emitter.clear', () => { + test('has the correct arguments', () => { + const emitter = createEmitter() + + type Source = Parameters + type Expected = [] + + expectTypeOf().toEqualTypeOf() + }) + + test('has the correct return type', () => { + const emitter = createEmitter() + const response = emitter.clear() + + type Source = typeof response + type Expected = void + + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('emitter.emit', () => { + test('has the correct return type', () => { + const emitter = createEmitter() + const response = emitter.emit('ping') + + type Source = typeof response + type Expected = void + + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('emitter.setOptions', () => { + test('has the correct return type', () => { + const emitter = createEmitter() + const response = emitter.setOptions({}) + + type Source = typeof response + type Expected = void + + expectTypeOf().toEqualTypeOf() + }) +}) diff --git a/src/main.spec.ts b/tests/events.spec.ts similarity index 99% rename from src/main.spec.ts rename to tests/events.spec.ts index 87c6d14..bd80122 100644 --- a/src/main.spec.ts +++ b/tests/events.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest' -import { createEmitter } from './main' +import { createEmitter } from '../src/main' async function timeout(delay: number = 0): Promise { return new Promise(resolve => { diff --git a/tsconfig.json b/tsconfig.json index 75abdef..3231acb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src/**/*.ts", "tests/**/*.ts"] }