@rickyli79/abortable-executor
v0.1.1
Published
Runs fully-trusted async functions with an interruptible sandbox (abort / timeout support).
Readme
⏱️ @rickyli79/abortable-executor
Run fully-trusted async functions with an interruptible sandbox (abort / timeout).
为完全授信的 async 函数提供「可中断执行」的运行器:支持调用方手动 abort 与超时中断。隔离边界由入参沙箱构成——运行器把入参包进可撤销的 Proxy 再传给函数,从不入侵函数内部、也从不向函数传递
AbortSignal。中断时 revoke 整个沙箱(硬切断);成功时返回值中残留的 Proxy 就地换回真实对象,保持身份(===)。零运行时依赖,纯 TS(ESM),面向 Node.js。
📑 目录
📖 项目简介
@rickyli79/abortable-executor 是为完全授信的 async 函数提供的「可中断执行」运行器。它围绕一个核心函数 run 展开:你把函数和它的入参交给它,它会保证这次调用可以被调用方(手动 abort)或超时(timeout)中断——而且完全不入侵函数内部。
隔离边界是入参沙箱:调用前,入参被包进可撤销的 Proxy。中断(signal abort 或 timeout)时会 revoke 整个沙箱——函数对入参的任何后续访问都会抛错,从而无法再污染外部。函数本身不会被停止,也永远不会收到 AbortSignal。
典型场景:
- 给长时间运行的异步工作加一个硬超时或手动「停止」按钮。
- 防止失控的函数继续写入它的入参、污染外部状态。
- 用类型化、可区分的错误区分「调用方中止」和「超时」。
它没有任何运行时依赖——实现是纯 TypeScript(ESM,由 tsup 构建),只依赖 Node.js 内建模块(node:async_hooks,用于可选的 timer 劫持 addon)。
✨ 特性
- ⏹️ 可中断执行:通过
AbortSignal或timeout中断;返回的 Promise 会以类型化错误 reject(AbortError/TimeoutError,二者都继承自AbortableError)。 - 🛡️ 非入侵:函数签名完全保留——它永远不会收到
AbortSignal,内部也从不被改写。整个隔离边界就是入参沙箱。 - 🔒 中断 = 硬切断:中断时 revoke 全部沙箱 Proxy;函数对入参的任何后续访问都会抛错,从而无法再污染外部。(函数本身并不停止。)
- 🎯 成功 = 保身份:返回值中残留的 Proxy 会就地换回真实对象,保持
===身份一致;成功路径不 revoke,不会留下「死 Proxy」。 - 🆔 跨 run 净化:若一个
run的沙箱 Proxy 被传进另一个run,会先解包回真实对象再重新包裹——每次 run 完全独立,中断一个不会影响另一个。 - 🥇 先到者赢:函数完成与中断同时发生时,先发生者决定结果。
- 🧩 零运行时依赖、纯 TS(ESM):无任何第三方依赖,面向 Node.js。
- ⏱️ 可选
captureTimersaddon:在本次 run 内劫持setTimeout/setInterval/setImmediate与Promise构造函数(以 AsyncLocalStorage 圈定到本次 run 的异步后裔):abort 时清已登记 timer、abort 后创建 timer/Promise 抛AbortError。默认关闭 = 零全局副作用。
📦 安装
使用 pnpm(本项目开发环境要求 pnpm ^11.18.0):
pnpm add @rickyli79/abortable-executor也可以使用 npm 或 yarn:
npm install @rickyli79/abortable-executor
# 或
yarn add @rickyli79/abortable-executor该包发布到 npm 公开注册表(
https://registry.npmjs.org),publishConfig.access = public。
模块格式(仅 ESM)
该包只提供 ESM 构建(由 tsup 生成):dist/index.js,类型声明 dist/index.d.ts。包级配置:type: module。
由于依赖 node:async_hooks(用于可选的 captureTimers addon),它面向 Node.js。
import {
run,
AbortableError,
AbortError,
TimeoutError,
isAbortableError,
} from "@rickyli79/abortable-executor";🚀 快速开始
示例一:基本用法
函数签名完全保留——入参以元组形式传入:
import { run } from "@rickyli79/abortable-executor";
// async 函数,正常拿到返回值
const total = await run(async (a: number, b: number) => a + b, [1, 2]);
// total === 3
// 同步函数也可以
const upper = await run((s: string) => s.toUpperCase(), ["hi"]);
// upper === "HI"
// 无参函数
const ok = await run(async () => "ok", []);示例二:abort / 超时中断与类型化错误
import {
run,
AbortableError,
AbortError,
TimeoutError,
isAbortableError,
} from "@rickyli79/abortable-executor";
const ctrl = new AbortController();
try {
const result = await run(
async (payload) => {
/* ... 长时间运行的授信工作 ... */
},
[payload],
{ timeout: 1000, signal: ctrl.signal },
);
// ...
} catch (e) {
if (e instanceof TimeoutError) {
// 超过 1000ms 超时
} else if (e instanceof AbortError) {
// 通过 ctrl.abort() 手动中止
} else if (isAbortableError(e)) {
// 任一中止域错误(即上面的两种)
} else {
// 函数自身抛出的错误,原样传播
}
}🧩 API 文档
包的核心导出是函数 run。
函数签名
function run<Func extends (...args: any[]) => any>(
fn: Func,
args: Parameters<Func>,
opts?: RunOptions,
): Promise<Awaited<ReturnType<Func>>>;参数
| 参数 | 类型 | 必填 | 说明 |
| ------ | ------------------ | ---- | ---------------------------------------------------------------------------- |
| fn | (...args) => any | 是 | 要执行的函数(async 或同步均可)。签名完全保留——永远不会收到 AbortSignal。 |
| args | Parameters<fn> | 是 | 与 fn 参数一一对应的元组。 |
| opts | RunOptions | 否 | 见下。 |
选项(RunOptions)
| 选项 | 类型 | 默认 | 说明 |
| -------------------- | ------------- | ------- | -------------------------------------------------------------------------------------- |
| opts.timeout | number (ms) | 无 | 超时毫秒数;undefined/Infinity = 不超时;<= 0 或 NaN 同步抛 TypeError。 |
| opts.signal | AbortSignal | 无 | 手动中止信号(只给 runner 用,不传给函数)。启动时已 abort → 立即 reject,不调用函数。 |
| opts.depth | number | 5 | 沙箱包裹深度;0 = 仅包顶层入参,嵌套对象是真实引用。 |
| opts.captureTimers | boolean | false | 启用 timer/Promise 劫持 addon(见「语义与注意事项」);默认关闭 = 零全局副作用。 |
返回值
Promise<Awaited<ReturnType<fn>>>:
- 正常完成时,resolve 为函数的返回值;其中残留的 Proxy 会被就地换回真实对象,保持与调用方对象的
===身份一致,且不 revoke。 - 中断时,以
AbortError(手动中止)或TimeoutError(超时)reject。 - 若函数自身抛错 / reject,则以相同原因 reject,原样传播。
- 选项非法时同步抛
TypeError。
错误类型
| 类型 | 含义 |
| ------------------------------------- | -------------- |
| AbortableError | 中止域错误基类 |
| AbortError extends AbortableError | 调用方手动中止 |
| TimeoutError extends AbortableError | 超时自动中断 |
| isAbortableError(e) | 中止域类型守卫 |
类型定义
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>>>;⚠️ 语义与注意事项
- 非入侵:运行器从不入侵函数内部,也从不向函数传递
AbortSignal。整个隔离边界就是入参沙箱。 - 中断 = 硬切断:abort / 超时时 revoke 全部沙箱 Proxy——函数对入参的任何后续访问都会抛错,从而无法再污染外部。函数本身并不停止(CPU 死循环无法被终止)。
- 成功 = 保身份:成功时返回值中残留的 Proxy 会就地换回真实对象(
===身份一致)。成功路径不 revoke,因此换不动的 Proxy(闭包、私有字段、WeakMap 键、冻结对象内)保持存活透明,不会变「死雷」。 - 跨 run 净化:若一个
run的沙箱 Proxy 被传进另一个run,会先解包回真实对象再重新包裹——每次 run 完全独立,中断一个不会影响另一个。 - 先到者赢:函数完成与中断同时发生时,先发生者决定结果。
- 启动即已 abort:调用时 signal 已 aborted → 立即以
AbortErrorreject,不调用函数。 - 选项校验:非法的
timeout(<= 0或NaN)或非AbortSignal的signal会同步抛TypeError。 - 逃逸语义:若函数在中断前已把沙箱对象缓存到闭包/全局/WeakMap 中,中断只切断对入参的访问,无法回收那些已逃逸的引用(函数完全授信的前提)。
captureTimers addon
传入 captureTimers: true 时,本次 run 会劫持全局 setTimeout/setInterval/setImmediate 与 Promise 构造函数(以 AsyncLocalStorage 圈定到本次 run 的异步后裔,不影响无关代码):
- abort 时:清掉函数内已登记的 timer(其回调不再触发)。
- abort 后:函数内再创建 timer 或
new Promise会抛AbortError(响亮失败,而非假启动)。 - 成功时:不清 timer(成功 = 正常完成,函数自己的后台 timer 归它管)。
- 引用计数:第一个
captureTimersrun 时 patch 全局,最后一个 settle 后恢复。 - 边界:非 timer 的纯异步 promise(I/O)无法 fail fast,只能等它 settle;捕获了内建引用的库可绕过劫持(授信函数前提)。
await run(fn, args, { captureTimers: true, timeout: 1000 });🌐 运行环境要求
- 仅 Node.js:包依赖
node:async_hooks(AsyncLocalStorage,用于可选的captureTimersaddon),面向 Node.js(无浏览器构建)。 - 仅 ESM:
type: module,无 CJS 构建。 - 零运行时依赖:运行时无任何第三方依赖。
🛠️ 开发
环境要求
- Node.js(建议 ≥ 22.x)
- pnpm
^11.18.0(devEngines.packageManager,不满足时会提示下载)
脚本
| 命令 | 说明 |
| ---------------------------- | ----------------------------------------------------------------------------- |
| pnpm run typecheck | 类型检查(tsc --noEmit) |
| pnpm test | 运行测试(vitest run,当前共 25 个测试) |
| pnpm run build | 构建(tsup,生成 ESM + d.ts) |
| pnpm run lint | Lint(oxlint) |
| pnpm run changelog | 生成 CHANGELOG.md(auto-changelog,keepachangelog 模板,起始版本 v0.1.0) |
| pnpm run changelog:preview | 生成预览版 CHANGELOG-preview.md |
目录结构
abortable-executor/
├── src/
│ ├── index.ts # 核心实现(run + 沙箱 + captureTimers addon)
│ └── index.test.ts # vitest 测试(25 个用例)
├── vitest.config.ts # 测试配置
├── tsconfig.json # TypeScript 配置
├── CONTEXT.md # 领域模型 / 术语
├── .github/workflows/ # CI / 自动发布(publish.yml + changelog-preview.yml)
└── package.json