diff --git a/Emittery.d.ts b/Emittery.d.ts new file mode 100644 index 0000000..76d7d92 --- /dev/null +++ b/Emittery.d.ts @@ -0,0 +1,137 @@ +export = Emittery; + +declare class Emittery { + /** + * Subscribe to an event. + * + * Returns an unsubscribe method. + * + * Using the same listener multiple times for the same event will result + * in only one method call per emitted event. + */ + on(eventName: string, listener: (eventData?: any) => any): Emittery.UnsubscribeFn; + + /** + * Remove an event subscription. + * + * If you don't pass in a `listener`, it will remove all listeners for that + * event. + */ + off(eventName: string, listener?: (eventData?: any) => any): void; + + /** + * Subscribe to an event only once. It will be unsubscribed after the first + * event. + * + * Returns a promise for the event data when `eventName` is emitted. + */ + once(eventName: string): Promise; + + /** + * Trigger an event asynchronously, optionally with some data. Listeners + * are called in the order they were added, but execute concurrently. + * + * Returns a promise for when all the event listeners are done. *Done* + * meaning executed if synchronous or resolved when an + * async/promise-returning function. You usually wouldn't want to wait for + * this, but you could for example catch possible errors. If any of the + * listeners throw/reject, the returned promise will be rejected with the + * error, but the other listeners will not be affected. + * + * Returns a promise for when all the event listeners are done. + */ + emit(eventName: string, eventData?: any): Promise; + + /** + * Same as `emit()`, but it waits for each listener to resolve before + * triggering the next one. This can be useful if your events depend on each + * other. Although ideally they should not. Prefer `emit()` whenever + * possible. + * + * If any of the listeners throw/reject, the returned promise will be + * rejected with the error and the remaining listeners will *not* be called. + * + * Returns a promise for when all the event listeners are done. + */ + emitSerial(eventName: string, eventData?: any): Promise; + + /** + * Subscribe to be notified about any event. + * + * Returns a method to unsubscribe. + */ + onAny(listener: (eventName: string, eventData?: any) => any): Emittery.UnsubscribeFn; + + /** + Remove an `onAny` subscription. + + If you don't pass in a `listener`, it will remove all `onAny` subscriptions. + */ + offAny(listener?: (eventName: string, eventData?: any) => any): void; + + /** + * Clear all event listeners on the instance. + */ + clear(): void; + + /** + * The number of listeners for the `eventName` or all events if not + * specified. + */ + listenerCount(eventName?: string): number; +} + +declare namespace Emittery { + /** + * Removes an event subscription. + */ + type UnsubscribeFn = () => void; + + /** + * Maps event names to their emitted data type. + */ + interface Events { + [eventName: string]: any; + } + + /** + * Async event emitter. + * + * Must list supported events and the data type they emit, if any. + * + * For example: + * + * ```ts + * import Emittery = require('emittery'); + * + * const ee = new Emittery.Typed<{value: string}, 'open' | 'close'>(); + * + * ee.emit('open'); + * ee.emit('value', 'foo\n'); + * ee.emit('value', 1); // TS compilation error + * ee.emit('end'); // TS compilation error + * ``` + */ + class Typed extends Emittery { + on(eventName: Name, listener: (eventData: EventDataMap[Name]) => any): Emittery.UnsubscribeFn; + on(eventName: Name, listener: () => any): Emittery.UnsubscribeFn; + + once(eventName: Name): Promise; + once(eventName: Name): Promise; + + off(eventName: Name, listener?: (eventData: EventDataMap[Name]) => any): void; + off(eventName: Name, listener?: () => any): void; + + onAny(listener: (eventName: Name, eventData: EventDataMap[Name]) => any): Emittery.UnsubscribeFn; + onAny(listener: (eventName: Name) => any): Emittery.UnsubscribeFn; + + offAny(listener?: (eventName: Name, eventData: EventDataMap[Name]) => any): void; + offAny(listener?: (eventName: Name) => any): void; + + emit(eventName: Name, eventData: EventDataMap[Name]): Promise; + emit(eventName: Name): Promise; + + emitSerial(eventName: Name, eventData: EventDataMap[Name]): Promise; + emitSerial(eventName: Name): Promise; + } +} diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..ed1afdd --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +/clocktyped.js diff --git a/examples/clock.js b/examples/clock.js new file mode 100755 index 0000000..79d1cc3 --- /dev/null +++ b/examples/clock.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +'use strict'; + +const Emittery = require('..'); + +class Clock extends Emittery { + constructor() { + super(); + this.startedAt = 0; + this.timer = null; + } + + tick() { + if (!this.timer) { + this.emit('error', new Error('Clock has not been started')); + return; + } + + const now = Date.now(); + const duration = now - this.startedAt; + + this.emit('tick', {duration, now}); + } + + start() { + if (this.timer) { + throw new Error('Clock has already been started'); + } + + this.startedAt = Date.now(); + this.timer = setInterval(this.tick.bind(this), 1000); + + this.emit('start'); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + } + + this.startedAt = 0; + this.timer = null; + + this.emit('stop'); + } +} + +function onTick({duration}) { + console.log(Math.floor(duration / 1000)); + + if (duration >= 6000) { + stop(); + } +} + +function onError(err) { + process.exitCode = 1; + console.error(err); + stop(); +} + +const timer = new Clock(); +const offTick = timer.on('tick', onTick); +const offError = timer.on('error', onError); + +function stop() { + offTick(); + offError(); + timer.stop(); +} + +timer.start(); +// Prints: +// 1 +// 2 +// 3 +// 4 +// 5 +// 6 diff --git a/examples/clocktyped.ts b/examples/clocktyped.ts new file mode 100755 index 0000000..0cc8484 --- /dev/null +++ b/examples/clocktyped.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env npx ts-node +import {setInterval} from 'timers'; +import Emittery = require('..'); + +interface TickData { + now: number; + duration: number; +} + +// Map Clock's events emitting data to the type of their data. +type EventDataMap = { + tick: TickData, + error: Error +}; + +// List of event which do not required data +type EmptyEvents = 'start' | 'stop'; + +class Clock extends Emittery.Typed { + private startedAt = 0; + private timer: NodeJS.Timer | null = null; + + public constructor() { + super(); + } + + private tick() { + if (!this.timer) { + this.emit('error', new Error('Clock has not been started')); + return; + } + + const now = Date.now(); + const duration = now - this.startedAt; + + this.emit('tick', {duration, now}); + } + + public start() { + if (this.timer) { + throw new Error('Clock has already been started'); + } + + this.startedAt = Date.now(); + this.timer = setInterval(this.tick.bind(this), 1000); + + this.emit('start'); + } + + public stop() { + if (this.timer) { + clearInterval(this.timer); + } + + this.startedAt = 0; + this.timer = null; + + this.emit('stop'); + } +} + +function onTick({duration}: TickData) { + console.log(Math.floor(duration / 1000)); + + if (duration >= 6000) { + stop(); + } +} + +function onError(err: Error) { + process.exitCode = 1; + console.error(err); + stop(); +} + +const timer = new Clock(); +const offTick = timer.on('tick', onTick); +const offError = timer.on('error', onError); + +function stop() { + offTick(); + offError(); + timer.stop(); +} + +timer.start(); +// Prints: +// 1 +// 2 +// 3 +// 4 +// 5 +// 6 diff --git a/examples/emit.js b/examples/emit.js new file mode 100755 index 0000000..be1c038 --- /dev/null +++ b/examples/emit.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +'use strict'; + +const Emittery = require('..'); + +const myEmitter = new Emittery(); + +// Emit event in next tick +myEmitter.emit('event'); + +// Register listener +myEmitter.on('event', () => console.log('an event occurred!')); +myEmitter.onAny(eventName => console.log('"%s" event occurred!', eventName)); + +// Prints: +// an event occurred! +// "event" event occurred! diff --git a/examples/emitonce.js b/examples/emitonce.js new file mode 100755 index 0000000..c036630 --- /dev/null +++ b/examples/emitonce.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +'use strict'; + +const Emittery = require('..'); + +const myEmitter = new Emittery(); + +// Emit events in next tick +myEmitter.emit('event', 1); +myEmitter.emit('event', 2); + +// Register listener for only the one event +myEmitter.once('event') + .then(count => console.log('an event occurred (#%d).', count)); + +// Prints: +// an event occurred (#1). diff --git a/examples/eventdata.js b/examples/eventdata.js new file mode 100755 index 0000000..ed7ea5b --- /dev/null +++ b/examples/eventdata.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +'use strict'; + +const Emittery = require('../'); + +const myEmitter = new Emittery(); + +// Only accept one event data parameter +myEmitter.emit('event', {a: true, b: true}, 'not', 'supported'); + +// Does not provide a context either. +myEmitter.on('event', function ({a, b}, ...args) { + console.log(a, b, args, this); +}); + +// Prints: +// true true [] undefined diff --git a/index.js b/index.js index 35f07a4..3e5ce9a 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,7 @@ function assertEventName(eventName) { } } -module.exports = class Emittery { +class Emittery { constructor() { this._events = new Map(); this._anyEvents = new Set(); @@ -105,4 +105,13 @@ module.exports = class Emittery { return count; } -}; +} + +// Subclass used to encourage TS users to type their events. +Emittery.Typed = class extends Emittery {}; +Object.defineProperty(Emittery.Typed, 'Typed', { + enumerable: false, + value: undefined +}); + +module.exports = Emittery; diff --git a/legacy.d.ts b/legacy.d.ts new file mode 100644 index 0000000..2e6f5f5 --- /dev/null +++ b/legacy.d.ts @@ -0,0 +1,2 @@ +import Emittery = require('./Emittery') +export = Emittery diff --git a/package.json b/package.json index c3d6b39..4d48d97 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "build": "babel --out-file=legacy.js index.js", "build:watch": "npm run build -- --watch", "prepublish": "npm run build", - "test": "xo && nyc ava" + "test": "xo && tsc --noEmit && nyc ava" }, "files": [ + "Emittery.d.ts", "index.js", - "legacy.js" + "legacy.js", + "legacy.d.ts" ], + "typings": "./Emittery.d.ts", "keywords": [ "event", "emitter", @@ -47,6 +50,7 @@ "promise" ], "devDependencies": { + "@types/node": "^8.5.2", "ava": "*", "babel-cli": "^6.26.0", "babel-core": "^6.26.0", @@ -54,7 +58,10 @@ "babel-plugin-transform-es2015-spread": "^6.22.0", "codecov": "^3.0.0", "delay": "^2.0.0", + "glob": "^7.1.2", "nyc": "^11.3.0", + "ts-node": "^4.1.0", + "typescript": "^2.6.2", "xo": "*" }, "babel": { diff --git a/readme.md b/readme.md index 5f4900f..f6330fe 100644 --- a/readme.md +++ b/readme.md @@ -51,7 +51,7 @@ Using the same listener multiple times for the same event will result in only on #### off(eventName, [listener]) -Unsubscribe to an event. +Remove an event subscription. If you don't pass in a `listener`, it will remove all listeners for that event. @@ -94,9 +94,9 @@ Returns a method to unsubscribe. #### offAny([listener]) -Unsubscribe an `onAny` listener. +Remove an `onAny` subscription. -If you don't pass in a `listener`, it will remove all `onAny` listeners. +If you don't pass in a `listener`, it will remove all `onAny` subscriptions. #### clear() @@ -106,6 +106,23 @@ Clear all event listeners on the instance. The number of listeners for the `eventName` or all events if not specified. +## TypeScript + +Definition for `emittery` and `emittery/legacy` are included. Use `import Emittery = require('emittery')` or `import Emittery = require('emittery/legacy')` to load the desired implementation. + +The default `Emittery` class does not let you type allowed event names and their associated data. However you can use `Emittery.Typed` with generics: + +```ts +import Emittery = require('emittery'); + +const ee = new Emittery.Typed<{value: string}, 'open' | 'close'>(); + +ee.emit('open'); +ee.emit('value', 'foo\n'); +ee.emit('value', 1); // TS compilation error +ee.emit('end'); // TS compilation error +``` + ## FAQ diff --git a/test/fixtures/compiles/emit.ts b/test/fixtures/compiles/emit.ts new file mode 100644 index 0000000..60a75e0 --- /dev/null +++ b/test/fixtures/compiles/emit.ts @@ -0,0 +1,6 @@ +import Emittery = require('../../..'); + +const ee = new Emittery(); + +ee.emit('anEvent'); +ee.emit('anEvent', 'some data'); diff --git a/test/fixtures/compiles/off.ts b/test/fixtures/compiles/off.ts new file mode 100644 index 0000000..e584620 --- /dev/null +++ b/test/fixtures/compiles/off.ts @@ -0,0 +1,9 @@ +import Emittery = require('../../..'); + +const ee = new Emittery(); + +ee.off('anEvent', () => undefined); +ee.off('anEvent', () => Promise.resolve()); + +ee.off('anEvent', data => undefined); +ee.off('anEvent', data => Promise.resolve()); diff --git a/test/fixtures/compiles/on.ts b/test/fixtures/compiles/on.ts new file mode 100644 index 0000000..97cfcef --- /dev/null +++ b/test/fixtures/compiles/on.ts @@ -0,0 +1,13 @@ +import Emittery = require('../../..'); + +const ee = new Emittery(); + +ee.on('anEvent', () => undefined); +ee.on('anEvent', () => Promise.resolve()); + +ee.on('anEvent', data => undefined); +ee.on('anEvent', data => Promise.resolve()); + +const off = ee.on('anEvent', () => undefined); + +off(); diff --git a/test/fixtures/compiles/tsconfig.json b/test/fixtures/compiles/tsconfig.json new file mode 100644 index 0000000..27a6731 --- /dev/null +++ b/test/fixtures/compiles/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["es2017"], + "module": "commonjs", + "strict": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + }, + "include": ["*.ts"] +} diff --git a/test/fixtures/fails/emit-extra.ts b/test/fixtures/fails/emit-extra.ts new file mode 100644 index 0000000..4eb97c6 --- /dev/null +++ b/test/fixtures/fails/emit-extra.ts @@ -0,0 +1,6 @@ +import Emittery = require('../../..'); + +const ee = new Emittery(); + +ee.emit('anEvent'); +ee.emit('anEvent', 'some data', 'and more'); diff --git a/test/fixtures/fails/on-extra.ts b/test/fixtures/fails/on-extra.ts new file mode 100644 index 0000000..6a90bbe --- /dev/null +++ b/test/fixtures/fails/on-extra.ts @@ -0,0 +1,6 @@ +import Emittery = require('../../..'); + +const ee = new Emittery(); + +ee.on('anEvent', (data, more) => undefined); + diff --git a/test/fixtures/tsconfig.json b/test/fixtures/tsconfig.json new file mode 100644 index 0000000..27a6731 --- /dev/null +++ b/test/fixtures/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["es2017"], + "module": "commonjs", + "strict": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + }, + "include": ["*.ts"] +} diff --git a/test/snapshots/types.js.md b/test/snapshots/types.js.md new file mode 100644 index 0000000..7e6743a --- /dev/null +++ b/test/snapshots/types.js.md @@ -0,0 +1,14 @@ +# Snapshot report for `test/types.js` + +The actual snapshot is saved in `types.js.snap`. + +Generated by [AVA](https://ava.li). + +## TS warns about invalid Emittery method calls + +> Snapshot 1 + + `test/fixtures/fails/emit-extra.ts (6,1): Expected 1-2 arguments, but got 3.␊ + test/fixtures/fails/on-extra.ts (5,18): Argument of type '(data: any, more: any) => undefined' is not assignable to parameter of type '(eventData?: any) => any'.␊ + test/fixtures/fails/on-extra.ts (5,19): Parameter 'data' implicitly has an 'any' type.␊ + test/fixtures/fails/on-extra.ts (5,25): Parameter 'more' implicitly has an 'any' type.` diff --git a/test/snapshots/types.js.snap b/test/snapshots/types.js.snap new file mode 100644 index 0000000..f05effc Binary files /dev/null and b/test/snapshots/types.js.snap differ diff --git a/test/types.js b/test/types.js new file mode 100644 index 0000000..ff27ab4 --- /dev/null +++ b/test/types.js @@ -0,0 +1,61 @@ +import path from 'path'; + +import test from 'ava'; +import glob from 'glob'; +import * as ts from 'typescript'; + +const compilerOptions = { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + strict: true, + noEmit: true +}; + +test('TS can compile valid Emittery method calls', assertAllCompile, 'test/fixtures/compiles'); +test('TS warns about invalid Emittery method calls', assertEachFail, 'test/fixtures/fails'); + +function assertAllCompile(t, srcDir) { + const fileNames = listFiles(srcDir); + const errors = compile(fileNames); + + t.is(errors.length, 0, errorMessage(errors)); +} + +function assertEachFail(t, srcDir) { + const fileNames = listFiles(srcDir).sort(); + const errors = compile(fileNames); + const filesWithErrors = errors + .map(err => (err.file ? err.file.fileName : null)) + .filter(Boolean); + + t.deepEqual(new Set(filesWithErrors), new Set(fileNames), 'Some files did not emit any compile error.'); + t.snapshot(errorMessage(errors)); +} + +function listFiles(srcRoot) { + return glob.sync('{*.js,*.ts}', { + cwd: path.resolve(srcRoot), + absolute: true + }); +} + +function compile(fileNames, options = compilerOptions) { + const program = ts.createProgram(fileNames, options); + const emitResult = program.emit(); + + return ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); +} + +function errorMessage(diagnosticList) { + return diagnosticList.map(diagnostic => { + if (!diagnostic.file) { + return `${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; + } + + const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + const fileName = path.relative(process.cwd(), diagnostic.file.fileName); + + return `${fileName} (${line + 1},${character + 1}): ${message}`; + }).join('\n'); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..21b3bd7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "alwaysStrict": true, + "lib": ["es2017"], + "module": "commonjs", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "target": "es2017" + }, + "exclude": ["test/fixtures"] +}