# @ziuchen/wormhole-client

通过 [wormhole.app](https://wormhole.app) 上传和下载文件——端到端加密，零运行时依赖。

## 安装

```sh
npm install -g @ziuchen/wormhole-client
# 或
pnpm add -g @ziuchen/wormhole-client
```

## CLI 用法

### 上传

```sh
wormhole upload <文件> [文件2 ...]
```

| 参数              | 简写 | 说明                                       |
| ----------------- | ---- | ------------------------------------------ |
| `--name <目录名>` | `-n` | 多文件上传时展示给接收方的文件夹名称       |
| `--expire <时间>` | `-e` | 有效期：`1h` `2h` `6h` `12h` `24h`         |
| `--limit <n>`     | `-l` | 最大下载次数：`1` `5` `10` `20` `50` `100` |

```sh
# 上传单个文件
wormhole upload report.pdf

# 上传多个文件并指定文件夹名称
wormhole upload *.log --name server-logs

# 设置 2 小时有效期，限制下载 5 次
wormhole upload secret.zip --expire 2h --limit 5
```

分享链接打印到 stdout；进度和状态信息输出到 stderr。

### 下载

```sh
wormhole download <url> [--output <目录>]
```

| 参数              | 简写 | 说明                  |
| ----------------- | ---- | --------------------- |
| `--output <目录>` | `-o` | 保存目录（默认：`.`） |

```sh
wormhole download "https://wormhole.app/abc123#key"
wormhole download "https://wormhole.app/abc123#key" --output ~/Downloads
```

## 编程式调用

```ts
import { createClient, parseWormholeUrl } from "@ziuchen/wormhole-client";
```

### `createClient(options?)`

创建同时支持上传和下载的客户端实例。所有网络请求均使用配置的 `fetch`、超时时间和重试策略。

```ts
import { createClient } from "@ziuchen/wormhole-client";

const client = createClient({
  timeout: 60_000, // 每次请求超时（毫秒），默认 60_000
  fetch: globalThis.fetch, // 自定义 fetch（默认 globalThis.fetch）
  retries: 3, // 最大重试次数（默认 3，0 表示不重试）
  apiBase: "https://wormhole.app/api", // API 基地址
  userAgent: "MyApp/2.0", // User-Agent 头（默认 "Wormhole-Client/1.0"）
  uploadConcurrency: 10, // 并发上传 B2 分块数（默认 10）
});
```

| 选项                | 类型            | 默认值                       | 说明                              |
| ------------------- | --------------- | ---------------------------- | --------------------------------- |
| `timeout`           | `number?`       | `60_000`                     | 每次网络请求的超时时间（毫秒）    |
| `fetch`             | `typeof fetch?` | `globalThis.fetch`           | 自定义 fetch 实现（mock、代理等） |
| `retries`           | `number?`       | `3`                          | 失败重试次数（0 禁用）            |
| `apiBase`           | `string?`       | `"https://wormhole.app/api"` | API 基地址                        |
| `userAgent`         | `string?`       | `"Wormhole-Client/1.0"`      | `User-Agent` 请求头               |
| `uploadConcurrency` | `number?`       | `10`                         | B2 分块上传最大并发数             |

### `client.upload(files, options?)`

```ts
const result = await client.upload(
  [
    { stream: fileStream, name: "report.pdf", size: 12345 },
    { buffer: pdfBytes, name: "data.csv" },
  ],
  {
    dirName: "quarterly-report", // 多文件上传时的文件夹名称
    lifetime: 7200, // 2 小时（可选：3600 | 7200 | 21600 | 43200 | 86400）
    maxDownloads: 10, // 可选：1 | 5 | 10 | 20 | 50 | 100
    signal: ac.signal, // AbortSignal，用于取消上传
    onProgress(uploaded, total) {
      process.stdout.write(`\r${uploaded}/${total} 字节`);
    },
  },
);

console.log(result.url); // https://wormhole.app/<id>#<key>
console.log(result.roomId); // room ID
console.log(result.lifetime); // 服务器确认的有效期（秒）
console.log(result.maxDownloads); // 服务器确认的最大下载次数
```

#### `UploadFile`

| 字段     | 类型                                           | 说明                                                                        |
| -------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
| `stream` | `ReadableStream<Uint8Array>` + `name` + `size` | 流式输入（跨平台）                                                          |
| `buffer` | `Uint8Array` + `name`                          | 内存缓冲区输入（跨平台）                                                    |
| `path`   | `string` + `name?`                             | 文件路径——**仅 Node.js**（请使用 `@ziuchen/wormhole-client/node` 便捷方法） |

#### `UploadOptions`

| 字段           | 类型                                        | 说明                                                   |
| -------------- | ------------------------------------------- | ------------------------------------------------------ |
| `dirName`      | `string?`                                   | 多文件传输的文件夹名称                                 |
| `lifetime`     | `number?`                                   | 有效期（秒）：`3600` `7200` `21600` `43200` `86400`    |
| `maxDownloads` | `number?`                                   | 下载次数限制：`1` `5` `10` `20` `50` `100`             |
| `onProgress`   | `(uploaded: number, total: number) => void` | 周期性回调，参数为已上传的累计字节数和加密后的总字节数 |
| `signal`       | `AbortSignal?`                              | 取消整个上传操作                                       |

#### `UploadResult`

| 字段           | 类型      | 说明                                         |
| -------------- | --------- | -------------------------------------------- |
| `url`          | `string`  | 包含加密密钥（在 URL fragment 中）的分享链接 |
| `roomId`       | `string`  | Room ID（URL 路径段）                        |
| `lifetime`     | `number?` | 服务器确认的有效期（秒）                     |
| `maxDownloads` | `number?` | 服务器确认的最大下载次数                     |

### `client.download(url, options?)`

将文件下载到内存，返回解密后的 `Uint8Array` 数据。

```ts
const result = await client.download("https://wormhole.app/abc123#key", {
  onInfo(torrent) {
    console.log(
      `共 ${torrent.files.length} 个文件：${torrent.files.map((f) => f.name).join(", ")}`,
    );
  },
  onProgress(downloaded, total) {
    process.stdout.write(`\r${downloaded}/${total} 字节`);
  },
  signal: ac.signal, // AbortSignal，用于取消下载
});

for (const f of result.files) {
  console.log(f.name, f.data.byteLength); // 文件名 & 解密后的字节数
}
```

#### `DownloadOptions`

| 字段         | 类型                                          | 说明                                           |
| ------------ | --------------------------------------------- | ---------------------------------------------- |
| `onInfo`     | `(torrent: ParsedTorrent) => void`            | 元数据解密完成后、开始下载前调用一次           |
| `onProgress` | `(downloaded: number, total: number) => void` | 周期性回调，参数为已下载的累计字节数和总字节数 |
| `signal`     | `AbortSignal?`                                | 取消整个下载操作                               |

#### `DownloadResult`

| 字段    | 类型                                   | 说明                                   |
| ------- | -------------------------------------- | -------------------------------------- |
| `name`  | `string`                               | Torrent 名称（多文件时为文件夹名称）   |
| `files` | `{ name: string; data: Uint8Array }[]` | 已解密的文件列表，包含文件名和原始数据 |

### `parseWormholeUrl(url)`

```ts
import { parseWormholeUrl } from "@ziuchen/wormhole-client";

const { roomId, masterKey } = parseWormholeUrl("https://wormhole.app/abc123#key");
// masterKey 为 Uint8Array
```

### 按需导入子路径（Tree-shaking）

如果只需要上传或下载功能，可从对应子路径导入，避免打包未使用的代码：

```ts
// 仅上传 —— 不会引入下载代码
import { createUploadClient } from "@ziuchen/wormhole-client/upload";
const client = createUploadClient({ timeout: 30_000 });
await client.upload(files, opts);

// 仅下载 —— 不会引入上传代码
import { createDownloadClient, parseWormholeUrl } from "@ziuchen/wormhole-client/download";
const client = createDownloadClient({ timeout: 30_000 });
await client.download(url, opts);
```

### Node.js 文件系统辅助（`@ziuchen/wormhole-client/node`）

从磁盘读取文件或将下载内容写入目录时，使用 Node.js 专属子路径：

```ts
import { createUploadClient } from "@ziuchen/wormhole-client/upload";
import { uploadFromPaths } from "@ziuchen/wormhole-client/node";

const client = createUploadClient();
const result = await uploadFromPaths(
  client,
  [{ path: "/tmp/report.pdf" }, { path: "/tmp/data.csv", name: "export.csv" }],
  {
    dirName: "quarterly-report",
    lifetime: 7200,
    onProgress(uploaded, total) {
      /* ... */
    },
  },
);
```

```ts
import { createDownloadClient } from "@ziuchen/wormhole-client/download";
import { downloadToDir } from "@ziuchen/wormhole-client/node";

const client = createDownloadClient();
const result = await downloadToDir(client, "https://wormhole.app/abc123#key", "/tmp/out", {
  onProgress(downloaded, total) {
    /* ... */
  },
});

console.log(result.outputDir); // "/tmp/out"
for (const f of result.files) {
  console.log(f.name, f.size); // 已保存的文件名和明文大小（字节）
}
```

## 工作原理

文件在本地使用 AES-128-GCM（[RFC 8188](https://www.rfc-editor.org/rfc/rfc8188) 流式格式）加密，再通过 wormhole.app 上传至 Backblaze B2。加密密钥不会离开客户端——它仅存在于 URL fragment 中，不会发送给服务器。接收方的浏览器（或本 CLI）下载密文后在本地解密。

## License

MIT
