Skip to content

Repository files navigation

English简体中文

⏱️ @rickyli79/abortable-executor

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.

npm version npm downloads


📑 Table of Contents


📖 Introduction

@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).

✨ Features

  • ⏹️ Interruptible execution: interrupt via AbortSignal or timeout; the returned promise rejects with a typed error (AbortError / TimeoutError, both under AbortableError).
  • 🛡️ 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 run passed into another run is 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 captureTimers addon: hijacks setTimeout/setInterval/setImmediate and Promise within the run (scoped via AsyncLocalStorage): on abort it clears registered timers and makes later timer/Promise creation throw AbortError. Off by default = zero global side effects.

📦 Installation

With pnpm (the project's dev environment requires pnpm ^11.18.0):

pnpm add @rickyli79/abortable-executor

Or with npm or yarn:

npm install @rickyli79/abortable-executor
# or
yarn add @rickyli79/abortable-executor

The package is published to the public npm registry (https://registry.npmjs.org) with publishConfig.access = public.

Module format (ESM only)

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";

🚀 Quick Start

Example 1: basic usage

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", []);

Example 2: abort & timeout with typed errors

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
  }
}

🧩 API

The package's core export is the function run.

Signature

function run<Func extends (...args: any[]) => any>(
  fn: Func,
  args: Parameters<Func>,
  opts?: RunOptions,
): Promise<Awaited<ReturnType<Func>>>;

Parameters

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.

Options (RunOptions)

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.

Return value

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) or TimeoutError (timeout).
  • If the function itself throws/rejects, it rejects with the same reason, propagated as-is.
  • Invalid options throw TypeError synchronously.

Error types

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

Type definitions

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>>>;

⚠️ Semantics & Notes

  1. Non-invasive: the runner never invades the function's internals and never passes it an AbortSignal. The whole isolation boundary is the argument sandbox.
  2. 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).
  3. 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".
  4. Cross-run sanitization: if a sandbox proxy from one run is passed into another run, it's unwrapped to the real object first, then re-wrapped — each run is fully independent, and interrupting one never breaks another.
  5. First-wins: when the function completes and an interrupt happen at the same time, the first to occur decides the outcome.
  6. Already-aborted signal: an already-aborted signal at call time rejects immediately with AbortError, without invoking the function.
  7. Option validation: invalid timeout (<= 0 or NaN) or a non-AbortSignal signal throws TypeError synchronously.
  8. 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).

The captureTimers addon

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 Promise inside the function throws AbortError (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 captureTimers run 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 });

🌐 Runtime Requirements

  • Node.js only: the package relies on node:async_hooks (AsyncLocalStorage) for the optional captureTimers addon, so it targets Node.js (no browser build).
  • ESM only: type: module; no CJS build.
  • Zero runtime dependencies: no third-party packages at runtime.

🛠️ Development

Requirements

  • Node.js (≥ 22.x recommended)
  • pnpm ^11.18.0 (devEngines.packageManager; prompts a download if not satisfied)

Scripts

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

Project structure

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

📄 License

MIT © Ricky Li

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages