---
date: 2026-05-10
topic: tsx 4.19 + ESM + npm workspace symlink + 跨文件 re-export → 误报 "does not provide an export named X"
context: packages/agent-client/service 在 /simplify pass 后 dev 模式启动失败
versions:
  - tsx@4.19.x
  - node@24.14.0
  - typescript@5.9.x
---

# 现象

`/simplify` pass 把 `brandSafeError` 等品牌脱敏工具从 service/agent.ts 提到 `@agent-client/shared`，
拆成 `shared/src/errors.ts` 单独文件，在 `shared/src/index.ts` 用 `export * from "./errors.js"`
（也试过 `export { brandSafeError, ... } from "./errors.js"`）re-export。

跑 `tsx watch src/main.ts`（service 的 dev 脚本）立即崩：

```
SyntaxError: The requested module '@agent-client/shared' does not provide an export named 'brandSafeError'
    at #asyncInstantiate (node:internal/modules/esm/module_job:319:21)
```

但**所有这些都正常**：

- `tsc --noEmit`：✅ 类型检查通过
- `npm run build` 后 `node dist/main.js`：✅ 编译产物可跑、import 成功
- `tsx -e "import { brandSafeError } from '@agent-client/shared'; ..."`：✅ 单条 eval 成功
- `cat dist/index.d.ts` / `cat dist/index.js`：✅ re-export 语句明确写在那
- 顺着 errors.js / errors.d.ts 的内容看：✅ `brandSafeError` 是导出函数

只有 `tsx <file>.ts` 模式（不论 watch 还是单跑）失败。错误位置在 agent.ts:2 的 import 处。

# 根因

tsx 4.19 处理这种组合：
1. ESM 模块（`"type": "module"`）
2. 通过 npm workspace symlink 引用的本地包（`file:../shared`）
3. 该包的入口文件用 `export ... from "./xyz.js"` re-export 另一个文件

**静态导入分析**这一步会失败，但错误消息**误导性极强**——它说"模块没有提供这个 export"，
让人以为是 export 语法/打包/路径问题，实际上 export 语句明明白白在那。tsx 的 loader 在
跨文件 re-export 边界上对 V8 的静态 ESM 解析做了某种干扰。

`tsx -e` 模式不走相同的 loader 路径，所以单条 import 能成功——这进一步坐实了 tsx 的
file-mode loader 在这种场景下的解析 bug。

# 修法（按推荐度排）

## ★ 推荐：单文件，不跨文件 re-export

把所有要 export 的内容都放在 `index.ts` 里，**不**做 `export * from "./xyz.js"`。一个文件里
直接 `export const`/`export function`/`export type` 全堆一起。

```ts
// shared/src/index.ts （好）
export const SERVICE_LABEL = "AI 服务";
export function brandSafeError(err: unknown): string { ... }
export const ClientMessageSchema = z.discriminatedUnion("type", [...]);
// ...
```

代价：一个文件可能长（200-300 行），但完全规避了 tsx 的解析坑。

## 备选 1：dev 也用编译后的 JS

把 service 的 `dev` script 从 `tsx watch src/main.ts` 改成 `tsc --watch & node --watch dist/main.js`。
两个进程：一个监听类型变化重新编译、一个监听 dist 重新跑。Node 24 的 `--watch` 已经稳定。

代价：多一道 tsc 编译延迟（首次启动 +2-3s），重启多一道 tsc 报错噪音。

## 备选 2：改用 Node 24 原生 TypeScript 支持

`node --experimental-strip-types src/main.ts` 在 Node 24 是默认行为（不需要 flag）。但需要
`.ts` 扩展显式写在 import 路径里（agent.ts → `import { runAgent } from "./agent.ts"`），
跟我们现在的 `.js` 扩展约定冲突。不推荐——破坏 ESM 编译链。

## 不推荐：换 ts-node / esbuild-node-loader / vitest 的运行器

都比 tsx 折腾，不能保证不撞别的坑。

# 验证

修完（应用"单文件"方案）后：

```bash
$ cd service && npx tsx src/main.ts
[service] listening ws://localhost:4291
[service] AGENT_CWD = ...
[service] ANTHROPIC_BASE_URL = ...
```

✅ 端口 listening、log 正常。

# 防御 / 一般化原则

1. **shared 包优先单文件**——只要不超过 500 行，扁平化 export 优于多文件 re-export
2. **如果非要拆多文件**：用 npm workspace 时 prefer named re-export (`export { x } from`) 优于 `export *`，且 dev 用编译后的 JS（不要走 tsx）
3. **debug 时跑 `node dist/main.js`** 跟 tsx 对比——如果编译产物可跑但 tsx 不行，就是 tsx 的问题
4. **tsx 的"does not provide an export named X"误报**是常见症状，不是真的少 export

# 跟 type:module 的关系

途中我也补加了 `"type": "module"` 到 shared/package.json（之前漏了）——这是**独立的**最佳实践，
应该补，但**不是这个 bug 的根因**。加完后 tsx 仍然报错。坑还在 tsx 的 cross-file re-export 处理。
