Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

events: add async emitter.when #15204

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,23 @@ Used when the native call from `process.cpuUsage` cannot be processed properly.

Used when `c-ares` failed to set the DNS server.

<a id="ERR_EVENTS_WHEN_CANCELED"></a>
### ERR_EVENTS_WHEN_CANCELED

Used when a `Promise` created using `emitter.when()` has been rejected because
the listener has been removed.

```js
const EventEmitter = require('events');

const ee = new EventEmitter();
ee.when('foo').then(() => { /* ... */ }).catch((reason) => {
console.log(reason.code); // ERR_EVENTS_WHEN_CANCELED
});

ee.removeAllListeners('foo');
```

<a id="ERR_FALSY_VALUE_REJECTION"></a>
### ERR_FALSY_VALUE_REJECTION

Expand Down
45 changes: 45 additions & 0 deletions doc/api/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,51 @@ to indicate an unlimited number of listeners.

Returns a reference to the `EventEmitter`, so that calls can be chained.

### async emitter.when(eventName)
<!-- YAML
added: REPLACEME
-->

* `eventName` {any} The name of the event.
* Returns: {Promise}

Creates and returns a `Promise` that is resolved with a one-time event handler.

```js
const myEmitter = new EventEmitter();

const p = myEmitter.when('foo')
.then((context) => {
console.log(context.args);
});

myEmitter.emit('foo', 1, 2, 3);
```

The `Promise` is resolved with a `context` object that contains two properties:

* `emitter` {EventEmitter} A reference to the `EventEmitter` instance.
* `args` {Array} An array containing the additional arguments passed to the
`emitter.emit()` function.

When a `Promise` is created, a corresponding `removeListener` event handler is
registered that will reject the `Promise` if it has not already been resolved.
When rejected in this manner, the rejection `reason` shall be a simple string
equal to `'canceled'`.

```js
const myEmitter = new EventEmitter();

const p = myEmitter.when('foo')
.catch((reason) => {
if (reason === 'canceled') {
// The promise was canceled using removeListener
}
});

myEmitter.removeAllListeners();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing event name.

myEmitter.removeAllListeners('foo');

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not needed in this case.

```

[`--trace-warnings`]: cli.html#cli_trace_warnings
[`EventEmitter.defaultMaxListeners`]: #events_eventemitter_defaultmaxlisteners
[`domain`]: domain.html
Expand Down
55 changes: 55 additions & 0 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ EventEmitter.prototype._maxListeners = undefined;
// added to it. This is a useful default which helps finding memory leaks.
var defaultMaxListeners = 10;

var errors;
function lazyErrors() {
if (!errors) {
errors = require('internal/errors');
}
return errors;
}

var createPromise;
var promiseReject;
var promiseResolve;

function lazyPromiseBinding() {
if (!createPromise) {
const util = process.binding('util');
createPromise = util.createPromise;
promiseReject = util.promiseReject;
promiseResolve = util.promiseResolve;
}
}

Object.defineProperty(EventEmitter, 'defaultMaxListeners', {
enumerable: true,
get: function() {
Expand Down Expand Up @@ -351,6 +372,40 @@ EventEmitter.prototype.prependOnceListener =
return this;
};

// An awaitable one-time event listener. Registers a once handler and
// returns an associated promise.
EventEmitter.prototype.when =
function when(type) {
lazyPromiseBinding();
const promise = createPromise();
let resolved = false;
const listener = (...args) => {
resolved = true;
this.removeListener(type, listener);
promiseResolve(promise, { emitter: this, args });
};
Object.defineProperties(listener, {
name: { value: `promise for '${type}'` },
promise: { value: promise }
});
// When a listener is removed, and the promise has not yet been
// resolved, reject it with a simple reason.
const cancel = (eventName, removedListener) => {
const errors = lazyErrors();
if (!resolved && eventName === type && removedListener === listener) {
this.removeListener('removeListener', cancel);
promiseReject(promise,
new errors.Error('ERR_EVENTS_WHEN_CANCELED', type));
}
};
// Do not use once because it will trigger a removeListener *before*
// the promise is resolved, which throws off the cancel logic when
// removeListener is called.
this.on(type, listener);
this.on('removeListener', cancel);
return promise;
};

// Emits a 'removeListener' event if and only if the listener was removed.
EventEmitter.prototype.removeListener =
function removeListener(type, listener) {
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA',
(enc) => `The encoded data was not valid for encoding ${enc}`);
E('ERR_ENCODING_NOT_SUPPORTED',
(enc) => `The "${enc}" encoding is not supported`);
E('ERR_EVENTS_WHEN_CANCELED',
(type) => `The when '${type}' promise was canceled`);
E('ERR_FALSY_VALUE_REJECTION', 'Promise was rejected with falsy value');
E('ERR_HTTP_HEADERS_SENT',
'Cannot %s headers after they are sent to the client');
Expand Down
62 changes: 62 additions & 0 deletions test/parallel/test-event-emitter-when.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const EventEmitter = require('events');

const ee = new EventEmitter();

{
ee.when('foo')
.then(common.mustCall((context) => {
assert.strictEqual(context.emitter, ee);
assert.deepStrictEqual(context.args, [1, 2, 3]);
}))
.catch(common.mustNotCall());
assert.strictEqual(ee.listenerCount('foo'), 1);
ee.emit('foo', 1, 2, 3);
assert.strictEqual(ee.listenerCount('foo'), 0);
}

{
ee.when('foo')
.then(common.mustCall(() => {
throw new Error('foo');
}))
.catch(common.mustCall((err) => {
assert.strictEqual(err.message, 'foo');
}));
assert.strictEqual(ee.listenerCount('foo'), 1);
ee.emit('foo');
assert.strictEqual(ee.listenerCount('foo'), 0);
}

{
ee.removeAllListeners();
ee.when('foo')
.then(common.mustNotCall())
.catch(common.expectsError({
code: 'ERR_EVENTS_WHEN_CANCELED',
type: Error,
message: 'The when \'foo\' promise was canceled'
}));
ee.removeAllListeners();
}

{
ee.removeAllListeners();
assert.strictEqual(ee.listenerCount(0), 0);
const promise = ee.when('foo');
promise.then(common.mustNotCall())
.catch(common.expectsError({
code: 'ERR_EVENTS_WHEN_CANCELED',
type: Error,
message: 'The when \'foo\' promise was canceled'
}));
const fn = ee.listeners('foo')[0];
assert.strictEqual(fn.name, 'promise for \'foo\'');
assert.strictEqual(fn.promise, promise);
ee.removeListener('foo', fn);
}

process.on('unhandledRejection', common.mustNotCall());
Copy link
Member

@targos targos Sep 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common.crashOnUnhandledRejection();