Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jedwards1211 committed Nov 12, 2024
1 parent f587d83 commit c35bb8e
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 1 deletion.
1 change: 1 addition & 0 deletions .mocharc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ const base = require('@jcoreio/toolchain-mocha/.mocharc.cjs')
const { getSpecs } = require('@jcoreio/toolchain-mocha')
module.exports = {
...base,
require: [...base.require, 'test/configure.ts'],
spec: getSpecs(['test']),
}
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,20 @@ memory-leak-proof function to wrap a promise to reject when a signal is aborted
[![Coverage Status](https://codecov.io/gh/jcoreio/abortable/branch/master/graph/badge.svg)](https://codecov.io/gh/jcoreio/abortable)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
[![npm version](https://badge.fury.io/js/%40jcoreio%2Fabortable.svg)](https://badge.fury.io/js/%40jcoreio%2Fabortable)

## `abortable(promise, signal)`

Creates a promise that fulfills when the given `promise` fulfills, or rejects when the given `signal` is aborted,
whichever comes first. If the signal is aborted, rejects with a `DOMException` with `name: 'AbortError'`.

Once the returned promise resolves or rejects, references to all promise and signal handlers will have been removed,
so that it doesn't unexpectedly retain any memory.

```ts
import { abortable } from '@jcoreio/abortable'

const abortController = new AbortContorller()
const { signal } = abortController

const promise = abortable(new Promise((r) => setTimeout(r, 10000)), signal)
```
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
"engines": {
"node": ">=16"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./dist/package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"default": "./dist/index.js"
}
},
"packageManager": "[email protected]",
"dependencies": {
"@babel/runtime": "^7.18.6"
Expand Down
37 changes: 37 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const newAbortError = () =>
new DOMException('This operation was aborted', 'AbortError')

const noop = () => {}

export function abortable<T>(
promise: Promise<T>,
signal: AbortSignal | undefined
): Promise<T> {
if (!signal) return promise
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
reject(newAbortError())
return
}
const cleanup = () => {
const callbacks = { resolve, reject }
// Prevent memory leaks. If the input promise never resolves, then the handlers
// below would retain this enclosing Promise's resolve and reject callbacks,
// which would retain the enclosing Promise and anything waiting on it.
// By replacing references to these callbacks, we enable the enclosing Promise to
// be garbage collected
resolve = noop
reject = noop
// Memory could also leak if the signal never aborts, unless we remove the abort
// handler
signal.removeEventListener('abort', onAbort)
return callbacks
}
const onAbort = () => cleanup().reject(newAbortError())
signal.addEventListener('abort', onAbort)
promise.then(
(value) => cleanup().resolve(value),
(error) => cleanup().reject(error)
)
})
}
62 changes: 62 additions & 0 deletions test/abortable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it } from 'mocha'
import { abortable } from '../src/index'
import { expect } from 'chai'
import { withResolvers } from './withResolvers'
import { tried } from './tried'

describe(`abortable`, function () {
it(`returns promise if signal is undefined`, async function () {
const promise = Promise.resolve(42)
expect(abortable(promise, undefined)).to.equal(promise)
})
it(`resolves if promise resolves first`, async function () {
const ac = new AbortController()
expect(await abortable(Promise.resolve(42), ac.signal)).to.equal(42)
ac.abort()
})
it(`rejects if promise rejects first`, async function () {
const ac = new AbortController()
await expect(abortable(Promise.reject(new Error('test')), ac.signal))
.to.be.rejectedWith(Error)
.that.eventually.deep.equals(new Error('test'))
ac.abort()
})
it(`rejects if signal is already aborted`, async function () {
const p = withResolvers<number>()
const ac = new AbortController()
ac.abort()
const [, error] = tried(() => ac.signal.throwIfAborted())()
await Promise.all([
expect(abortable(p.promise, ac.signal))
.to.be.rejectedWith(DOMException)
.that.eventually.deep.equals(error),
p.resolve(42),
])
})
it(`rejects if signal aborts before promise resolves`, async function () {
const p = withResolvers<number>()
const ac = new AbortController()
await Promise.all([
expect(abortable(p.promise, ac.signal))
.to.be.rejectedWith(DOMException)
.that.eventually.deep.equals(
new DOMException('This operation was aborted', 'AbortError')
),
ac.abort(),
p.resolve(42),
])
})
it(`rejects if signal aborts before promise rejects`, async function () {
const p = withResolvers<number>()
const ac = new AbortController()
await Promise.all([
expect(abortable(p.promise, ac.signal))
.to.be.rejectedWith(DOMException)
.that.eventually.deep.equals(
new DOMException('This operation was aborted', 'AbortError')
),
ac.abort(),
p.reject(new Error('test')),
])
})
})
3 changes: 3 additions & 0 deletions test/configure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
chai.use(chaiAsPromised)
55 changes: 55 additions & 0 deletions test/tried.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
type Tried<T> = [T | undefined, any]

/**
* Helper for getting the result or error of an operation inline, inspired
* by proposed JS try expressions
*
* Examples:
*
* Sync function:
* const [result, error] = tried((x) => x * 2)(21) // [42, undefined]
* const [result, error] = tried(() => { throw new Error('test') })() // [undefined, new Error('test)]
*
* Async function:
* const [result, error] = await tried(async (x) => x * 2)(21) // [42, undefined]
* const [result, error] = await tried(async () => { throw new Error('test') })() // [undefined, new Error('test)]
*
* Promise:
* const [result, error] = await tried(Promise.resolve(42)) // [42, undefined]
* const [result, error] = await tried(Promise.reject(new Error('test'))) // [undefined, new Error('test')]
*/
export function tried<Args extends any[], T>(
fn: (...args: Args) => T
): (...args: Args) => Tried<T>
export function tried<Args extends any[], T>(
fn: (...args: Args) => PromiseLike<T>
): (...args: Args) => Promise<Tried<T>>
export function tried<T>(promise: PromiseLike<T>): Promise<Tried<T>>
export function tried<Args extends any[], T>(
x: PromiseLike<T> | ((...args: Args) => T | PromiseLike<T>)
): ((...args: Args) => Tried<T> | Promise<Tried<T>>) | Promise<Tried<T>> {
if (isPromiseLike<T>(x)) {
return (x as Promise<T>).then(
(value) => [value, undefined],
(reason) => [undefined, reason]
)
}
return (...args: Args) => {
if (typeof x !== 'function') {
return [
undefined,
new Error('invalid input, must be a function or a Promise'),
] as const
}
try {
const result = x(...args)
return isPromiseLike<T>(result) ? tried(result) : [result, undefined]
} catch (error) {
return [undefined, error]
}
}
}

function isPromiseLike<T>(x: any): x is PromiseLike<T> {
return typeof x?.then === 'function'
}
25 changes: 25 additions & 0 deletions test/withResolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type PromiseWithResolvers<T> = {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (reason?: any) => void
}

/**
* Userland implementation of [Promise.withResolvers]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers}.
* Once we upgrade to Node 22, we can switch to the builtin.
*/
export function withResolvers<T>(): PromiseWithResolvers<T>
export function withResolvers<T>(
this: PromiseConstructor
): PromiseWithResolvers<T>
export function withResolvers<T>(
this: PromiseConstructor | undefined
): PromiseWithResolvers<T> {
const PromiseConstructor = this || Promise
let resolve, reject
const promise = new PromiseConstructor<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve: resolve!, reject: reject! }
}
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"extends": "./node_modules/@jcoreio/toolchain-typescript/tsconfig.json",
"include": ["./src", "./test"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"compilerOptions": {
"lib": ["es2019", "DOM"]
}
}

0 comments on commit c35bb8e

Please sign in to comment.