English | 简体中文
Run fully-trusted async functions with an interruptible sandbox (abort / timeout).
Execute async functions that can be interrupted by the caller (manual abort) or by a timeout. The isolation boundary is a sandbox of revocable Proxies wrapping the arguments — the runner never invades the function's internals and never passes it an
AbortSignal. On interrupt the sandbox is revoked (hard cut); on success the proxies left in the return value are swapped back in place, preserving identity (===). Zero runtime dependencies, pure TS (ESM), Node.js.
- 📖 Introduction
- ✨ Features
- 📦 Installation
- 🚀 Quick Start
- 🧩 API
⚠️ Semantics & Notes- 🌐 Runtime Requirements
- 🛠️ Development
- License
@rickyli79/abortable-executor is an "interruptible execution" runner for fully-trusted async functions. It is built around a single function, run: you hand it a function and its arguments, and it guarantees that the call can be interrupted by the caller (manual abort) or by a timeout — without invading the function's internals.
The isolation boundary is an argument sandbox: before the call, the arguments are wrapped in revocable Proxies. Interrupting (signal abort or timeout) revokes the whole sandbox — any later access to the arguments throws, so the function can no longer pollute the outside world. The function itself is never stopped and never receives an AbortSignal.
Typical use cases:
- Run long-running async work with a hard timeout or a manual stop button.
- Protect the outside world from a runaway function that keeps writing to its inputs.
- Get typed, distinguishable errors for "aborted by caller" vs "timed out".
It has zero runtime dependencies — the implementation is pure TypeScript (ESM, built by tsup), relying only on Node.js built-ins (node:async_hooks for the optional timer-capture addon).
- ⏹️ Interruptible execution: interrupt via
AbortSignalortimeout; the returned promise rejects with a typed error (AbortError/TimeoutError, both underAbortableError). - 🛡️ Non-invasive: the function's signature is fully preserved — it never receives an
AbortSignal, and its internals are never touched. The entire isolation boundary is the argument sandbox. - 🔒 Interrupt = hard cut: on interrupt all sandbox proxies are revoked; any later access to the arguments throws, so the function can no longer pollute the outside. (The function itself isn't stopped.)
- 🎯 Identity preserved on success: proxies left in the return value are swapped back to the real objects in place —
===identity is preserved, and the success path never revokes (no "dead proxies"). - 🆔 Cross-run sanitization: a sandbox proxy from one
runpassed into anotherrunis unwrapped to the real object first, then re-wrapped — each run is fully independent; interrupting one never breaks another. - 🥇 First-wins: when completion and interruption race, the first to happen decides the outcome.
- 🧩 Zero runtime dependencies, pure TS (ESM): no third-party dependencies; built for Node.js.
- ⏱️ Optional
captureTimersaddon: hijackssetTimeout/setInterval/setImmediateandPromisewithin the run (scoped viaAsyncLocalStorage): on abort it clears registered timers and makes later timer/Promise creation throwAbortError. Off by default = zero global side effects.
With pnpm (the project's dev environment requires pnpm ^11.18.0):
pnpm add @rickyli79/abortable-executorOr with npm or yarn:
npm install @rickyli79/abortable-executor
# or
yarn add @rickyli79/abortable-executorThe package is published to the public npm registry (
https://registry.npmjs.org) withpublishConfig.access = public.
The package ships an ESM-only build (generated by tsup): dist/index.js with type declarations dist/index.d.ts. Package-level config: type: module.
Because it relies on node:async_hooks (for the optional captureTimers addon), it targets Node.js.
import {
run,
AbortableError,
AbortError,
TimeoutError,
isAbortableError,
} from "@rickyli79/abortable-executor";The function's signature is fully preserved — pass the arguments as a tuple:
import { run } from "@rickyli79/abortable-executor";
// async function, normal return value
const total = await run(async (a: number, b: number) => a + b, [1, 2]);
// total === 3
// synchronous functions work too
const upper = await run((s: string) => s.toUpperCase(), ["hi"]);
// upper === "HI"
// no-argument functions
const ok = await run(async () => "ok", []);import {
run,
AbortableError,
AbortError,
TimeoutError,
isAbortableError,
} from "@rickyli79/abortable-executor";
const ctrl = new AbortController();
try {
const result = await run(
async (payload) => {
/* ... long-running trusted work ... */
},
[payload],
{ timeout: 1000, signal: ctrl.signal },
);
// ...
} catch (e) {
if (e instanceof TimeoutError) {
// timed out after 1000ms
} else if (e instanceof AbortError) {
// manually aborted via ctrl.abort()
} else if (isAbortableError(e)) {
// any abort-domain error (the two above)
} else {
// the function's own error, propagated as-is
}
}The package's core export is the function run.
function run<Func extends (...args: any[]) => any>(
fn: Func,
args: Parameters<Func>,
opts?: RunOptions,
): Promise<Awaited<ReturnType<Func>>>;| Parameter | Type | Required | Description |
|---|---|---|---|
fn |
(...args) => any |
yes | The function to execute (async or sync). Its signature is fully preserved — it never receives an AbortSignal. |
args |
Parameters<fn> |
yes | Tuple of arguments, one per fn parameter. |
opts |
RunOptions |
no | See below. |
| Option | Type | Default | Description |
|---|---|---|---|
opts.timeout |
number (ms) |
none | Timeout in milliseconds; undefined/Infinity = no timeout; <= 0 or NaN throws TypeError synchronously. |
opts.signal |
AbortSignal |
none | Manual abort signal (used by the runner only, never passed to the function). An already-aborted signal rejects immediately without invoking the function. |
opts.depth |
number |
5 |
Sandbox wrapping depth; 0 = only top-level arguments wrapped, nested objects are real references. |
opts.captureTimers |
boolean |
false |
Enable the timer/Promise hijacking addon (see "Semantics & Notes"); off by default = zero global side effects. |
Promise<Awaited<ReturnType<fn>>>:
- On success, resolves to the function's return value; proxies left in it are swapped back in place, so identity (
===) with the caller's objects is preserved, and nothing is revoked. - On interrupt, rejects with
AbortError(manual abort) orTimeoutError(timeout). - If the function itself throws/rejects, it rejects with the same reason, propagated as-is.
- Invalid options throw
TypeErrorsynchronously.
| Type | Meaning |
|---|---|
AbortableError |
Base class of the abort domain |
AbortError extends AbortableError |
Interrupted by the caller |
TimeoutError extends AbortableError |
Interrupted by the timeout |
isAbortableError(e) |
Type guard for the abort domain |
export interface RunOptions {
timeout?: number;
signal?: AbortSignal;
depth?: number;
captureTimers?: boolean;
}
export class AbortableError extends Error {}
export class AbortError extends AbortableError {}
export class TimeoutError extends AbortableError {}
export function isAbortableError(e: unknown): e is AbortableError;
export function run<Func extends (...args: any[]) => any>(
fn: Func,
args: Parameters<Func>,
opts?: RunOptions,
): Promise<Awaited<ReturnType<Func>>>;- Non-invasive: the runner never invades the function's internals and never passes it an
AbortSignal. The whole isolation boundary is the argument sandbox. - Interrupt = hard cut: on abort/timeout, all sandbox proxies are revoked — any later access to the arguments throws, so the function can no longer pollute the outside. The function itself isn't stopped (a CPU-bound infinite loop can't be terminated).
- Success = identity preserved: on success, proxies remaining in the return value are swapped back to real objects in place (
===identity). The success path never revokes, so proxies that can't be swapped (closures, private fields, WeakMap keys, frozen objects) stay alive and transparent — no "dead proxies". - Cross-run sanitization: if a sandbox proxy from one
runis passed into anotherrun, it's unwrapped to the real object first, then re-wrapped — each run is fully independent, and interrupting one never breaks another. - First-wins: when the function completes and an interrupt happen at the same time, the first to occur decides the outcome.
- Already-aborted signal: an already-aborted signal at call time rejects immediately with
AbortError, without invoking the function. - Option validation: invalid
timeout(<= 0orNaN) or a non-AbortSignalsignalthrowsTypeErrorsynchronously. - Escape semantics: if the function cached a sandbox object in a closure/global/WeakMap before the interrupt, interrupting only cuts access to the arguments — it can't reclaim those already-escaped references (the function is assumed fully trusted).
Pass captureTimers: true to hijack the global setTimeout/setInterval/setImmediate and the Promise constructor for that run (scoped to the run's async descendants via AsyncLocalStorage, not affecting unrelated code):
- On abort: clears the function's registered timers (their callbacks no longer fire).
- After abort: creating a timer or
new Promiseinside the function throwsAbortError(loud failure instead of a fake start). - On success: timers are NOT cleared (success = normal completion; background timers belong to the function).
- Reference counting: the first
captureTimersrun patches globals, the last settle restores them. - Boundary: plain async I/O (non-timer promises) can't fail fast — it only settles; libraries holding built-in references can bypass the hijack (trusted-function premise).
await run(fn, args, { captureTimers: true, timeout: 1000 });- Node.js only: the package relies on
node:async_hooks(AsyncLocalStorage) for the optionalcaptureTimersaddon, so it targets Node.js (no browser build). - ESM only:
type: module; no CJS build. - Zero runtime dependencies: no third-party packages at runtime.
- Node.js (≥ 22.x recommended)
- pnpm
^11.18.0(devEngines.packageManager; prompts a download if not satisfied)
| Command | Description |
|---|---|
pnpm run typecheck |
Type check (tsc --noEmit) |
pnpm test |
Run tests (vitest run, currently 25 tests) |
pnpm run build |
Build (tsup → ESM + d.ts) |
pnpm run lint |
Lint (oxlint) |
pnpm run changelog |
Generate CHANGELOG.md (auto-changelog, keepachangelog template, starting at v0.1.0) |
pnpm run changelog:preview |
Generate preview CHANGELOG-preview.md |
abortable-executor/
├── src/
│ ├── index.ts # Core implementation (run + sandbox + captureTimers addon)
│ └── index.test.ts # vitest tests (25 cases)
├── vitest.config.ts # Test config
├── tsconfig.json # TypeScript config
├── CONTEXT.md # Domain model / terminology
├── .github/workflows/ # CI / auto-publish (publish.yml + changelog-preview.yml)
└── package.json