cancellationtoken
Version:
A composable token for cancelling asynchronous operations.
202 lines (183 loc) • 5.96 kB
text/typescript
const NOOP = () => {}
/**
* A token that can be passed around to inform consumers of the token that a
* certain operation has been cancelled.
*/
class CancellationToken {
private _reason: any
private _callbacks?: Set<(reason?: any) => void> = new Set()
/**
* A cancellation token that is already cancelled.
*/
public static readonly CANCELLED: CancellationToken = new CancellationToken(true, true)
/**
* A cancellation token that is never cancelled.
*/
public static readonly CONTINUE: CancellationToken = new CancellationToken(false, false)
/**
* Whether the token has been cancelled.
*/
public get isCancelled(): boolean {
return this._isCancelled
}
/**
* Why this token has been cancelled.
*/
public get reason(): any {
if (this.isCancelled) {
return this._reason
} else {
throw new Error('This token is not cancelled.')
}
}
/**
* Make a promise that resolves when the async operation resolves,
* or rejects when the operation is rejected or this token is cancelled.
*/
public racePromise<T>(asyncOperation: Promise<T>): Promise<T> {
if (!this.canBeCancelled) {
return asyncOperation
}
return new Promise<T>((resolve, reject) => {
// we could use Promise.finally here as soon as it's implemented in the major browsers
const unregister = this.onCancelled((reason) =>
reject(new CancellationToken.CancellationError(reason)),
)
asyncOperation.then(
(value) => {
resolve(value)
unregister()
},
(err) => {
reject(err)
unregister()
},
)
})
}
/**
* Throw a {CancellationToken.CancellationError} if this token is cancelled.
*/
public throwIfCancelled(): void {
if (this._isCancelled) {
throw new CancellationToken.CancellationError(this._reason)
}
}
/**
* Invoke the callback when this token is cancelled.
* If this token is already cancelled, the callback is invoked immediately.
* Returns a function that unregisters the cancellation callback.
*/
public onCancelled(cb: (reason?: any) => void): () => void {
if (!this.canBeCancelled) {
return NOOP
}
if (this.isCancelled) {
cb(this.reason)
return NOOP
}
/* istanbul ignore next */
this._callbacks?.add(cb)
return () => this._callbacks?.delete(cb)
}
private constructor(
/**
* Whether the token is already cancelled.
*/
private _isCancelled: boolean,
/**
* Whether the token can be cancelled.
*/
public readonly canBeCancelled: boolean,
) {}
/**
* Create a {CancellationToken} and a method that cancels it.
*/
public static create(): {token: CancellationToken; cancel: (reason?: any) => void} {
const token = new CancellationToken(false, true)
const cancel = (reason?: any) => {
if (token._isCancelled) return
token._isCancelled = true
token._reason = reason
/* istanbul ignore next */
token._callbacks?.forEach((cb) => cb(reason))
delete token._callbacks // release memory
}
return {token, cancel}
}
/**
* Create a {CancellationToken} and a method that cancels it.
* The token will be cancelled automatically after the specified timeout in milliseconds.
*/
public static timeout(ms: number): {token: CancellationToken; cancel: (reason?: any) => void} {
const {token, cancel: originalCancel} = CancellationToken.create()
const timer = setTimeout(() => originalCancel(CancellationToken.timeout), ms)
const cancel = (reason?: any) => {
if (token._isCancelled) return
clearTimeout(timer)
originalCancel(reason)
}
return {token, cancel}
}
/**
* Create a {CancellationToken} that is cancelled when all of the given tokens are cancelled.
*
* This is like {Promise<T>.all} for {CancellationToken}s.
*/
public static all(...tokens: CancellationToken[]): CancellationToken {
// If *any* of the tokens cannot be cancelled, then the token we return can never be.
if (tokens.some((token) => !token.canBeCancelled)) {
return CancellationToken.CONTINUE
}
const combined = CancellationToken.create()
let countdown = tokens.length
const handleNextTokenCancelled = () => {
if (--countdown === 0) {
const reasons = tokens.map((token) => token._reason)
combined.cancel(reasons)
}
}
tokens.forEach((token) => token.onCancelled(handleNextTokenCancelled))
return combined.token
}
/**
* Create a {CancellationToken} that is cancelled when at least one of the given tokens is cancelled.
*
* This is like {Promise<T>.race} for {CancellationToken}s.
*/
public static race(...tokens: CancellationToken[]): CancellationToken {
// If *any* of the tokens is already cancelled, immediately return that token.
for (const token of tokens) {
if (token._isCancelled) {
return token
}
}
const combined = CancellationToken.create()
let unregistrations: (() => void)[]
const handleAnyTokenCancelled = (reason?: any) => {
unregistrations.forEach((unregister) => unregister()) // release memory
combined.cancel(reason)
}
unregistrations = tokens.map((token) => token.onCancelled(handleAnyTokenCancelled))
return combined.token
}
}
/* istanbul ignore next */
namespace CancellationToken {
/**
* The error that is thrown when a {CancellationToken} has been cancelled and a
* consumer of the token calls {CancellationToken.throwIfCancelled} on it.
*/
export class CancellationError extends Error {
public constructor(
/**
* The reason why the token was cancelled.
*/
public readonly reason: any,
) {
super('Operation cancelled')
Object.setPrototypeOf(this, CancellationError.prototype)
}
}
}
export default CancellationToken