@cancjs/coroutine
v1.0.0
Published
Cancelable generator-based drop-in replacements for async/await and async iterators.
Maintainers
Readme
Introduction
An async function cannot be stopped from the outside. Its promise is created and driven by the
engine, and nothing in the language lets a caller interrupt the function between two await
points.
This package brings that control back. A generator function wrapped with canc.async returns a
CancelablePromise, and
every step written as yield* canc.await(...) is a point where cancellation can take effect. The
code keeps the shape of an async function, with yield* where await used to be.
cancGen.async, in the mirror namespace, does the same for async function*. The
toolbox provides adapters for
wrapping existing APIs, and
decorators bring coroutines
to class methods. See the repository for the full ecosystem.
Features
- direct replacements for
asyncfunctions and async generators - cancellation reaches into a running coroutine, not just into the promise it returned
- full type inference through
yield*, no casts and noanysteps - combinator helpers (
all,race,any,allSettled,try) that preserve tuple types for awaitstyle consumption with per-item cancellation- works as a class method, with or without decorators
Getting Started
Installation
npm install @cancjs/coroutine @cancjs/promise@cancjs/promise is a peer dependency. This package is core tier: it follows strict semver, so
the default caret pin, ^1, is safe. See
Versioning for the full policy.
Usage
import * as canc from '@cancjs/coroutine';
const loadInvoice = canc.async(function* (invoiceId: string) {
const invoice = yield* canc.await(fetchInvoice(invoiceId));
const customer = yield* canc.await(fetchCustomer(invoice.customerId));
return { invoice, customer };
});
const pending = loadInvoice('inv-2041');
// Rejects the coroutine with a CancelError at its current step. The customer
// request is aborted if it is already in flight, and never starts otherwise.
pending.cancel();Independent steps run together and cancel together:
const loadDashboard = canc.async(function* () {
const [invoices, payments] = yield* canc.await.all([
fetchInvoices(),
fetchPayments()
]);
return summarize(invoices, payments);
});The flat names are exported next to the namespace aliases, so this is the same code:
import { cancAsync, cancAwait } from '@cancjs/coroutine';
const loadInvoice = cancAsync(function* (invoiceId: string) {
return yield* cancAwait(fetchInvoice(invoiceId));
});How It Works
Where this comes from
Before async/await was standardized, libraries like co already wrote asynchronous code in
direct style: a generator yields a promise, a driver awaits it and resumes the generator with the
result. TC39 standardized this exact pattern as async/await in ES2017. An async function
compiles to a generator state machine with a built-in promise driver, the same architecture that
co used, but hidden inside the engine and hardcoded to native Promise.
Hiding the driver is what removes cancellation. Nobody outside the function holds the handle that
decides whether the next step should run at all. This package puts the driver back in userland.
The cost is a yield* per step. The gain is interruption at every one of them, with type inference
that co never had, combinator helpers that mirror Promise.all/race/any, and async generator
support for producing cancelable streams. The rest stays as close to native semantics as possible:
return, throw, try/finally and delegation to other generators all work the way they do in
an async function.
What cancel does
Canceling the returned promise stops the coroutine at its current step. The step's promise is
canceled, the CancelError is thrown back into the generator at the point where it is suspended,
and the code after that point does not run.
A canceled coroutine rejects with CancelError. Without a handler, this triggers
unhandledRejection. Use @cancjs/unhandled-rejection at app entry or handle cancellation
explicitly with catchCancel/suppressCancel.
Because the error is thrown into the generator, try/catch and try/finally behave the way
they do in an async function. A finally block still runs on cancellation, and its own steps
run shielded, so cleanup cannot be canceled halfway:
const checkout = canc.async(function* (orderId: string) {
const reservation = yield* canc.await(reserveStock(orderId));
try {
return yield* canc.await(chargeCard(orderId));
} finally {
yield* canc.await(releaseReservation(reservation.id));
}
});Why yield* and not yield
At runtime yield promise and yield* canc.await(promise) do the same thing. The difference is
typing. A bare yield is typed by the generator-wide next type, which TypeScript cannot narrow
per step, so the resumed value comes back as unknown. canc.await(promise) returns a one-shot
generator whose return type carries Awaited<T>, and yield* delegation reads that type back.
The starred form is the one to write.
The distinction also keeps the two yield roles separate. In the async generator namespace
(cancGen.async), a bare yield emits a value to the consumer, just as it does in a native
async function*. Using yield* for awaiting preserves that: yield means emit, yield* means
await. The same rule applies in the regular namespace for consistency.
The combinator helpers exist as canc.await.all and friends rather than plain statics for the
same reason: they reconstruct tuple inference across the delegation.
Description
Two namespaces
Both namespaces are the same runtime machinery with different mental models, and each has its own entry point:
import * as canc from '@cancjs/coroutine'; // async/await world
import * as cancGen from '@cancjs/coroutine/gen'; // async function* worldIn a canc.async body a bare yield is an await. In a cancGen.async body a bare yield emits
a value to the consumer, and awaiting is always written yield* cancGen.await(...). Mixing
helpers from one namespace into a body of the other produces wrong values silently, so keep a body
in one dialect.
Awaiting several things at once
canc.await.all, .race, .any, .allSettled and .try fold a combinator into a single step.
Cancellation semantics come from
@cancjs/promise:
race and any cancel the losers, all cancels the rest on the first rejection.
const [profile, orders] = yield * canc.await.all([
fetchProfile(id),
fetchOrders(id)
]);Consuming an async iterable
canc.forAwait walks a source and runs a callback per item. Every pull is a cancellation point,
and canceling the coroutine closes the source:
const collectTokens = canc.async(function* (prompt: string) {
let answer = '';
yield* canc.forAwait(streamCompletion(prompt), (token) => {
answer += token;
if (answer.length > 4000) {
return false; // stops the loop, the source is closed
}
});
return answer;
});The callback can be a plain function, a generator function, or a coroutine. Use a generator function when the per-item work itself has to be cancelable:
yield* canc.forAwait(chunkStream, function* (chunk) {
yield* canc.await(saveChunk(chunk));
});canc.forAwait.toArray(source) collects a finite source into an array instead.
Producing an async iterable
cancGen.async turns a generator function into a cancelable async generator. Inside it, yield
emits and yield* cancGen.await(...) awaits:
import * as cancGen from '@cancjs/coroutine/gen';
const exportVideo = cancGen.async(function* (chunkIds: string[]) {
for (let index = 0; index < chunkIds.length; index++) {
yield* cancGen.await(transcodeChunk(chunkIds[index])); // internal step
yield Math.round(((index + 1) / chunkIds.length) * 100); // progress, emitted
}
});
for await (const progress of exportVideo(chunkIds)) {
updateProgressBar(progress);
}cancGen.forAwait consumes another source from inside a producer without emitting its items, and
cancGen.delegate(source) re-emits them. Delegation needs its own helper because a synchronous
generator cannot yield* an async iterable.
Class methods
canc.async wraps a generator function into a plain function, so placing it on a class is the
caller's decision. There are several ways to do it, depending on whether you use decorators and
whether you need TypeScript-correct types at the call site.
With decorators
The decorators package does it declaratively. The getter style is the recommended form in TypeScript:
import * as canc from '@cancjs/coroutine';
import { AsyncMethod } from '@cancjs/decorators';
class InvoiceService {
@AsyncMethod()
get load() {
return canc.async(function* (this: InvoiceService, invoiceId: string) {
return yield* canc.await(fetchInvoice(invoiceId));
}, this);
}
@AsyncMethod({ bind: true }) // per instance, safe to detach as a callback
get loadBound() {
return canc.async(function* (this: InvoiceService, invoiceId: string) {
return yield* canc.await(fetchInvoice(invoiceId));
}, this);
}
}A getter's return type is inferred from its body, so canc.async(...) flows through correctly
and the call site sees CancelablePromise<T>.
In plain JavaScript the shorter method style works too, because there is no static type to be wrong:
class IssueClient {
@AsyncMethod()
*loadIssue(issueId) {
return yield* canc.await(this.api.issue(issueId));
}
}In TypeScript, a method decorator cannot change the declared return type of the method it
decorates, so a decorated *load() generator keeps its generator type at the call site and every
caller needs a cast. Use the getter style instead.
With asyncMethod / bindMethod
Two helpers do the same work without decorators. Call them in the constructor. asyncMethod is the
runtime counterpart of @AsyncMethod({ bind: true }) and bindMethod of @BindMethod(), so the
member's kind decides what happens to it, exactly as it does under the decorators.
On a getter, both read it once, bind the result to the instance, and install it as an own property, so the getter is never called again. Neither wraps here: the getter already returned the finished function.
import * as canc from '@cancjs/coroutine';
class InvoiceService {
constructor() {
// per instance, equivalent to @AsyncMethod({ bind: true })
canc.asyncMethod(this, 'load');
}
get load() {
return canc.async(function* (this: InvoiceService, invoiceId: string) {
return yield* canc.await(fetchInvoice(invoiceId));
}, this);
}
}On a generator method, or a class field holding a generator function, the member is still raw, so
asyncMethod wraps it with canc.async bound to the instance while bindMethod only binds it.
This is the shorter form, and it has the same TypeScript problem as the decorated method style
above, so keep it for JavaScript:
class InvoiceService {
constructor() {
canc.asyncMethod(this, 'load');
}
*load(invoiceId) {
return yield* canc.await(fetchInvoice(invoiceId));
}
}asyncMethod takes coroutine options as a third argument, which the decorators do not:
canc.asyncMethod(this, 'load', { shield: true }).
Prototype assignment (JS only)
In plain JavaScript, a generator method can be replaced on the prototype directly:
class InvoiceService {
*load(invoiceId) {
return yield* canc.await(fetchInvoice(invoiceId));
}
}
InvoiceService.prototype.load = canc.async(InvoiceService.prototype.load);This is the cheapest form: one wrapped function per class, shared by all instances. In TypeScript, the call site still sees the generator's return type, and there is no clean way to correct it. Interface merging to redeclare the method's type causes "overload signature not compatible with its implementation signature". Use a getter style instead.
Class field
A class field avoids the prototype entirely, at the cost of one wrapped function per instance:
class InvoiceService {
load = canc.async(function* (this: InvoiceService, invoiceId: string) {
return yield* canc.await(fetchInvoice(invoiceId));
}, this);
}This is the simplest form for one-off classes, but the method does not participate in super
lookup and cannot be overridden from a subclass through the prototype chain.
Things that do not work
- passing an
async functionor anasync function*tocanc.async. There is nothing to drive. The returned promise rejects with aTypeErrorsaying so. Pass a plain generator function, and turn eachawaitintoyield* canc.await(...), which is the point: anawaitinside an async body escapes the cancel chain. - wrapping a coroutine again.
canc.asyncthrows aTypeErroron the spot rather than trying to drive theCancelablePromiseit would get back as if it were a generator. yield canc.await(x)without the star. The step resumes with a generator object instead of the value, and cancellation is never wired.- annotating a body as
Generator<unknown, T, any>, or asany. Both collapse step types. Let inference do its work, or useAsyncResult<T>where a generator has no enclosing wrapper to carry its type. - awaiting a plain, non-cancelable promise and expecting the work to stop. The chain stops, the
operation does not. Make it cancelable at its source with
cancelifyorpromisifyfrom the toolbox.
API
@cancjs/coroutine
| Export | Alias | Description |
| ----------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------- |
| cancAsync(genFn, ctx?, options?) | canc.async | Wraps a generator function into a function returning a CancelablePromise |
| cancAwait(value) | canc.await | One step, used as yield* cancAwait(value) |
| cancAwait.all / .race / .any / .allSettled / .try | | Combinators folded into a single step, tuple types preserved |
| cancForAwait(source, callback) | canc.forAwait | Consumes an async or sync iterable, one cancellation point per item |
| cancForAwait.toArray(source) | | Collects a source into an array |
| asyncMethod(instance, key, options?) | | Installs the member as an own property, wrapping a method or field with cancAsync |
| bindMethod(instance, key) | | Installs the member as an own property, bound to the instance, never wrapped |
| BreakError, isBreakError | | Breaking out of a stream from deeper code |
| AsyncResult<T> | | Return type for a generator body that has no enclosing wrapper |
options are
CancelablePromise options
and configure the promise the coroutine returns. ctx sets this for the generator body.
@cancjs/coroutine/gen
| Export | Alias | Description |
| -------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
| cancGenAsync(genFn, options?) | cancGen.async | Wraps a generator function into a function returning a cancelable async generator |
| cancGenAwait(value) | cancGen.await | Internal step inside a producer, not emitted |
| cancGenAwait.all / .race / .any / .allSettled / .try | | Same combinators for producer bodies |
| cancGenForAwait(source, callback) | cancGen.forAwait | Consumes a source inside a producer without emitting its items |
| cancGenForAwait.toArray(source) | | Collects a source into an array |
| cancGenDelegate(source) | cancGen.delegate | Re-emits another async iterable to the consumer |
| AsyncGenResult<E, R> | | Return type for a producer body, emit type and return type |
Compatibility
Node.js 18 and later, current browsers, TypeScript 4.2 and later. Generators are required, so an
ES5 build target needs downlevelIteration. Everything else follows
@cancjs/promise.
Documentation
yieldvsyield*for the typing limitation behind the starred form, and how redux-saga and MobXflowhit the same wall@cancjs/promisefor the cancellation model itself- Decorators for class methods
- Toolbox for making existing APIs cancelable
- Examples:
demo-coroutinefor the basics,app-ai-rag-pipelineandapp-ws-progressfor streaming producers and consumers
Contributing
You are welcome to participate through issues and pull requests!
