diff --git a/README.md b/README.md index 4021b96..849e8a5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ -

- canc ⮿ a crafty foundation for cancelable promises -

+
+ canc ⮿ A crafty foundation for cancelable promises +
 
+
-

- - License - - PRs Welcome -

+
+ +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](#contributing) + +

Cancelable promise ecosystem based on native Promise: coroutines, async iterators, decorators, utilities, third-party library helpers. @@ -15,404 +16,171 @@ Cancelable promise ecosystem based on native Promise: coroutines, a --- - -## Features + -* cancelable promise implementation built on top of ES Promise -* generator-based cancelable replacements for `async..await` and async iterators -* lazily evaluated cancelable promises -* cancelable Fetch API -* utility toolbox (`delay`, `timeout`, etc) -* library helpers (Axios, Bluebird, RxJS, etc) -* decorators for TypeScript and Babel -* base packages to be used with custom promise implementation -* UMD and ESM builds for modern and legacy browsers and Node.js -* TypeScript-ready - -## Packages - -### Cancelable Promises - -Cancellation-aware promise utilities: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameVersionDescription
- @cancjs/promise - - - Version - - - Cancelable promise implementation based on ES Promise -
- @cancjs/coroutine - - - Version - - - Cancelable generator-based drop-in replacements for async..await and async iterators -
- @cancjs/fetch - - - Version - - - Cross-platform Fetch API that uses cancelable promises -
- @cancjs/lazy-promise - - - Version - - - Cancelable lazily evaluated promise-like class -
- @cancjs/toolbox - - - Version - - - A collection of cancellation-aware promise helper functions and ponyfills -
- -### Native Promises - -General-purpose promise utilities that use built-in `Promise` as promise implementation where applicable: - - - - - - - - - - - - - - - - - - - - - - - - - - -
PackageVersionDescription
- @cancjs/coroutine-native - - - Version - - - Generator-based drop-in replacements for async..await and async iterators -
- @cancjs/lazy-promise-native - - - Version - - - Lazily evaluated promise-like class -
- @cancjs/toolbox - - - Version - - - A collection of promise helper functions -
- - - -## How It Works +```ts +import * as canc from '@cancjs/coroutine'; -Cancellation is a special form of promise rejection with cancel error that triggers registered handlers for the entire cancellation-aware promise chain. +const loadTrip = canc.async(function* (tripId: string) { + const hotels = yield* canc.await(searchHotels(tripId)); + const flights = yield* canc.await(searchFlights(tripId)); + return { hotels, flights }; +}); -`canc` promises implement two-way cancellation mechanism that treats promise chains as subscriptions: +const tripPromise = loadTrip('lis-42'); -* cancellation propagates down the promise chain when parent promise is canceled - -* cancellation bubbles up the chain when all child promises are canceled and parent promise value is no longer consumed - -Cancellation bubbling can be explicitly disabled on parent promise in case a promise causes side effects that shouldn't be implicitly discarded. +// The user navigated away. One call stops the whole tree. +tripPromise.cancel(); +``` -Two-way cancellation mechanism is supported for all common ways to establish a promise chain, including `all`, `race`, etc composition methods and coroutine `yield`. +## Features -A chain is cancelable only if it consists of `canc` promises. This requires to use cancellation-aware wrappers for Fetch API and third-party librararies, `async` and `async*` need to be replaced with cancelable generator-based coroutines. +- ❎ cancelable promise that extends native `Promise` +- 🔃 two-way, deep cancellation: propagates down the chain, bubbles up when a value goes unclaimed +- ⚡ generator coroutines as drop-in replacements for `async`/`await` and async iterators +- 🧰 utility toolbox: `debounce`, `delay`, `timeout`, lazy evaluation and other helpers +- 🔌 cancelable `fetch` and third-party integrations: React, Express, Axios and more +- 🎀 class method decorators: standard and legacy (TypeScript, Babel) +- 🧬 twin ecosystem packages for native `Promise` +- 📦 cross-platform: Node.js, Deno, Bun and browsers +- 🟦 extensive TypeScript support ## Motivation -Promise cancellation is highly beneficial in real life scenarios yet it's not a part of existing ECMAScript specification. JavaScript API like Fetch `AbortController` use their own mechanisms that aren't unified with native promises. - - - -A situation that is common in modern JavaScript applications is that a process like network request that stands behind long-running asynchronous task is abortable, consumers need to unsubscribe from results and abort initial process when it's no longer needed. This eventually becomes harder with uncancelable promises when a task is composed of smaller independent tasks. - -See [examples](#examples) for more use cases. - - - -### Background - -* **No official solution**. [Native cancelable promises](https://github.com/tc39/proposal-cancelable-promises) were incompatible with ES6 promise semantics, provided one-way cancellation, used unwieldy cancel tokens and have been abandoned. - -* **Bluebird stepped aside**. Bluebird has bulky stable API and has been largely superseded by ES promises where applicable, particularly due to `async..await`. [Two-way cancellation](http://bluebirdjs.com/docs/api/cancellation.html) is disabled by default and incompatible with native promise semantics. +Cancellation is standard in languages with async primitives, from Kotlin coroutines to Swift tasks to Rust futures. JavaScript is an unfortunate exception: a promise has no way to be stopped once started, and `async`/`await` is syntax sugar over promises, so it inherits the same limitation. -* **No universal third-party options**. JavaScript community provides no comprehensive alternatives based on native promises. Renowned `p-*` [package collection](https://github.com/sindresorhus/promise-fun#packages) only supports one-way cancellation and targets Node.js. +In practice this means wasted work. A component unmounts while its data is still loading. A route changes but the previous page's API calls keep going. A user cancels a search but the requests and retries continue. The deeper a task is composed of subtasks, the harder it gets to stop cleanly. -* **Observables aren't a magic bullet**. Observables can provide a superset of promise features, as well as cancellation. However, observables don't offer expressive sugar similar to `async..await`, cancellation may be lost in promise interop. Observables are push-based and cannot displace pull-based `async*` async iterators. [RxJS](https://github.com/ReactiveX/rxjs) is commonly used implementation with complex API, no [native observable](https://github.com/tc39/proposal-observable) implementation exists yet. +The platform has `AbortController`. It gives you a signal you can pass to `fetch` and other APIs, but the signal has to be threaded through every function call, gaps between steps need manual guards, and `AbortError` has to be filtered from real errors at every catch boundary. For a single `fetch` call this is manageable. For a chain of requests, parallel work and third-party libraries that may or may not accept a signal, the boilerplate adds up fast and the signal micromanagement becomes a cost of its own. -### Comparison +`canc` moves cancellation into the promise chain itself. Cancel one promise and every step below it stops. Combinators carry the same behavior: `race` cancels the losers, and when every promise inside an `all` is canceled, the `all` itself is canceled too. No signals to thread, no guards to write. -| | `canc` | native + `AbortController` | [p-cancelable](https://github.com/sindresorhus/p-cancelable) | [alkemics/CancelablePromise](https://github.com/alkemics/CancelablePromise) | [c-promise2](https://github.com/DigitalBrainJS/c-promise) | [Bluebird](http://bluebirdjs.com/docs/api/cancellation.html) | -|---|---|---|---|---|---|---| -| Native `Promise` subclass | yes | n/a | no (wraps) | no (wraps) | no (wraps) | no (own implementation) | -| Deep (chain-wide) cancellation | yes | manual (thread signal yourself) | no (single promise) | no (single promise) | yes | yes | -| Rejection-based (normal try/catch) | yes | yes (`AbortError`) | yes | no (silent skip) | no (never settles) | no (never settles by default) | -| Two-way propagation (bubble up + flow down) | yes | no | no | no | no | no | -| `AbortSignal` interop | yes (`@cancjs/fetch`, toolbox helpers) | native | no | no | no | no | -| Actively maintained | yes | n/a (platform) | yes | no | no | no (cancellation feature frozen) | +### The problem, in code -`canc` is the only entry that combines a native `Promise` subclass with deep, two-way, -rejection-based cancellation. `AbortController` is the maintained platform primitive but only -threads a signal, it doesn't propagate cancellation through a chain on its own. The `p-*`-style -packages and alkemics wrap a single promise and skip silently instead of rejecting. c-promise2 has -deep cancellation but isn't a `Promise` subclass and has gone quiet. Bluebird's two-way -cancellation predates today's `async..await`-centric ecosystem, is off by default, and promises -never settle on cancel instead of rejecting. +Plain `async`/`await` cannot be interrupted. The caller walks away, the work does not: -## Performance +```ts +async function loadTrip(tripId: string) { + const trip = await fetchTrip(tripId); + // The user already left. Both searches still go out. + const [hotels, flights] = await Promise.all([ + searchHotels(trip.city), + searchFlights(trip.city), + ]); + return { trip, hotels, flights }; +} +``` -`canc` wraps every `Promise` operation in cancellation bookkeeping, and that costs something. -Full numbers (methodology, machine specs, per-suite breakdowns, browser-lane results) live in -[`docs/benchmarks.md`](docs/benchmarks.md); the short version: - -In a simulated request waterfall (5 sequential + 3 parallel requests, 30% canceled mid-flight), -`canc` runs well under a microsecond slower per request than a hand-rolled native `Promise` + -`AbortController` baseline, a relative tax that has ranged from roughly 20% to 80% across repeated runs depending on machine -load, typically landing in the 25-50% band, that is -dwarfed by any real network or timer latency. **For I/O-bound flows (the common case), this -overhead is negligible.** A fraction of a microsecond of bookkeeping disappears next to a request -that takes milliseconds. The cost shows up more in tight, cancellation-heavy loops (a -mount/unmount-cancel component lifecycle simulation runs several times slower than native) and in -raw construct/chain throughput under isolated microbenchmarks, where `canc` lands roughly one order -of magnitude behind native `Promise` and is mixed against Bluebird depending on the shape of the -chain. Combinator internals (`all`/`race`/`any`/`allSettled`) were reworked to skip an extra -per-item allocation; the measured effect ranges from a modest win to roughly noise-level depending -on the case and run (see `docs/benchmarks.md`), and combinators remain well short of native and -mixed against Bluebird. - -Memory follows the same pattern. A single `canc` promise costs a few hundred bytes more than a -native one. For high-concurrency workloads (thousands of promises in flight at once, long-lived -subscriptions, or streaming/pagination patterns that keep many chains alive), budget roughly an -extra 2 MB of retained heap per 1,000 in-flight promises versus native. If a workload creates and -discards promises faster than it can await them, this is the number to watch. - -None of this changes the tradeoff to make in a given app: `canc` gives you real, rejection-based, -two-way cancellation without hand-wiring `AbortController` through every call site. Where that's -worth a few hundred bytes and a low single-digit-microsecond tax per operation, it's worth using; -where a hot loop constructs and cancels promises far faster than any I/O it wraps, measure against -your own budget first. - - +`AbortController` fixes the interruption, and the cost is spread over every layer. The signal becomes a parameter of everything, every gap between steps needs a guard, and the caller has to sort cancellation out of real failures: + +```ts +async function loadTrip(tripId: string, signal: AbortSignal) { + const trip = await fetchTrip(tripId, signal); + // Not every API takes a signal, and every gap between steps needs a guard. + signal.throwIfAborted(); + const [hotels, flights] = await Promise.all([ + searchHotels(trip.city, signal), + searchFlights(trip.city, signal), + ]); + return { trip, hotels, flights }; +} -## Compatibility +const controller = new AbortController(); -Packages rely on following ECMAScript 2015+ features: `Symbol` (ES2018 for async iterators), `Reflect`, `Promise` (ES2018 for `finally`, ES2020 for `allSettled`), `Object.assign`, `Object.setPrototypeOf`. +loadTrip('lis-42', controller.signal).catch((err) => { + if (err.name === 'AbortError') return; // expected, not a bug + throw err; +}); -### TypeScript +controller.abort(); +``` -TypeScript floor is 4.2. Each package ships two `.d.ts` variants and resolves the right one -automatically, no consumer configuration needed. TS >= 4.7 reads `exports["."].types` and gets -`dist/types/*.d.ts`. Older TS falls back to `typesVersions` (pre-4.7 resolvers don't read -`exports.types`) and gets `dist/types-ts4.2/*.d.ts`. +With `canc` the plumbing goes away. The signature stays clean, the steps stay readable, and canceling the returned promise stops everything below the current step: -The `-ts4.2` variant is produced from the standard output via `downlevel-dts`, plus a follow-up -patch for `Awaited` which predates `downlevel-dts`'s own transform coverage. Verified against -a pinned matrix (TS 4.2 / 4.7 / 5.0 / 5.4 / latest) by compiling fixture projects against the -built tarballs. +```ts +const loadTrip = canc.async(function* (tripId: string) { + const trip = yield* canc.await(fetchTrip(tripId)); + const [hotels, flights] = yield* canc.await.all([ + searchHotels(trip.city), + searchFlights(trip.city), + ]); + return { trip, hotels, flights }; +}); -### Build targets +const tripPromise = loadTrip('lis-42'); -All four build outputs (CJS, ESM, UMD, minified UMD) compile from the same ES5-targeted -TypeScript source, only the module wrapper differs. CJS is `dist/index.cjs` (`main` field), ESM -is `dist/index.mjs` (`module` field), UMD is `dist/index.umd.js` and `dist/index.umd.min.js` -(`