# @ziuchen/wormhole-client

Upload and download files via [wormhole.app](https://wormhole.app) — end-to-end encrypted, zero runtime dependencies.

## Install

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

## CLI

### Upload

```sh
wormhole upload <file> [file2 ...]
```

| Flag              | Short | Description                                                |
| ----------------- | ----- | ---------------------------------------------------------- |
| `--name <dir>`    | `-n`  | Directory name shown to the recipient (multi-file uploads) |
| `--expire <time>` | `-e`  | Expiry: `1h` `2h` `6h` `12h` `24h`                         |
| `--limit <n>`     | `-l`  | Max downloads: `1` `5` `10` `20` `50` `100`                |

```sh
# single file
wormhole upload report.pdf

# multiple files with a custom folder name
wormhole upload *.log --name server-logs

# expire in 2 hours, allow only 5 downloads
wormhole upload secret.zip --expire 2h --limit 5
```

The share URL is printed to stdout; progress and status messages go to stderr.

### Download

```sh
wormhole download <url> [--output <dir>]
```

| Flag             | Short | Description                          |
| ---------------- | ----- | ------------------------------------ |
| `--output <dir>` | `-o`  | Destination directory (default: `.`) |

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

## Programmatic API

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

### `createClient(options?)`

Create a combined upload+download client. All network requests use the configured `fetch`, timeout, and retry policy.

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

const client = createClient({
  timeout: 60_000, // per-request timeout in ms (default: 60_000)
  fetch: globalThis.fetch, // custom fetch (default: globalThis.fetch)
  retries: 3, // max retries on transient errors (default: 3)
  apiBase: "https://wormhole.app/api", // API base URL (default: wormhole.app)
  userAgent: "MyApp/2.0", // User-Agent header (default: "Wormhole-Client/1.0")
  uploadConcurrency: 10, // concurrent B2 chunk uploads (default: 10)
});
```

| Option              | Type            | Default                      | Description                                     |
| ------------------- | --------------- | ---------------------------- | ----------------------------------------------- |
| `timeout`           | `number?`       | `60_000`                     | Per-request timeout in milliseconds             |
| `fetch`             | `typeof fetch?` | `globalThis.fetch`           | Custom fetch implementation (mock, proxy, etc.) |
| `retries`           | `number?`       | `3`                          | Max retries on transient errors (0 to disable)  |
| `apiBase`           | `string?`       | `"https://wormhole.app/api"` | API base URL                                    |
| `userAgent`         | `string?`       | `"Wormhole-Client/1.0"`      | `User-Agent` header value                       |
| `uploadConcurrency` | `number?`       | `10`                         | Max concurrent B2 chunk uploads                 |

### `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", // folder name for multi-file uploads
    lifetime: 7200, // 2 hours (3600 | 7200 | 21600 | 43200 | 86400)
    maxDownloads: 10, // (1 | 5 | 10 | 20 | 50 | 100)
    signal: ac.signal, // AbortSignal to cancel the upload
    onProgress(uploaded, total) {
      process.stdout.write(`\r${uploaded}/${total} bytes`);
    },
  },
);

console.log(result.url); // https://wormhole.app/<id>#<key>
console.log(result.roomId); // room ID
console.log(result.lifetime); // confirmed lifetime in seconds (server value)
console.log(result.maxDownloads); // confirmed max downloads (server value)
```

#### `UploadFile`

| Field    | Type                                           | Description                                                                        |
| -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| `stream` | `ReadableStream<Uint8Array>` + `name` + `size` | Stream input (cross-platform)                                                      |
| `buffer` | `Uint8Array` + `name`                          | In-memory buffer input (cross-platform)                                            |
| `path`   | `string` + `name?`                             | File path — **Node.js only** (use `@ziuchen/wormhole-client/node` for convenience) |

#### `UploadOptions`

| Field          | Type                                        | Description                                                                |
| -------------- | ------------------------------------------- | -------------------------------------------------------------------------- |
| `dirName`      | `string?`                                   | Folder name for multi-file transfers                                       |
| `lifetime`     | `number?`                                   | Expiry in seconds: `3600` `7200` `21600` `43200` `86400`                   |
| `maxDownloads` | `number?`                                   | Download limit: `1` `5` `10` `20` `50` `100`                               |
| `onProgress`   | `(uploaded: number, total: number) => void` | Called repeatedly with cumulative bytes uploaded and total encrypted bytes |
| `signal`       | `AbortSignal?`                              | Cancel the entire upload operation                                         |

#### `UploadResult`

| Field          | Type      | Description                                           |
| -------------- | --------- | ----------------------------------------------------- |
| `url`          | `string`  | Share URL with encryption key in the fragment         |
| `roomId`       | `string`  | Room ID (the path segment of the URL)                 |
| `lifetime`     | `number?` | Actual lifetime in seconds as confirmed by the server |
| `maxDownloads` | `number?` | Actual max downloads as confirmed by the server       |

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

Downloads files into memory. Returns decrypted file data as `Uint8Array`.

```ts
const result = await client.download("https://wormhole.app/abc123#key", {
  onInfo(torrent) {
    console.log(`${torrent.files.length} file(s): ${torrent.files.map((f) => f.name).join(", ")}`);
  },
  onProgress(downloaded, total) {
    process.stdout.write(`\r${downloaded}/${total} bytes`);
  },
  signal: ac.signal, // AbortSignal to cancel the download
});

for (const f of result.files) {
  console.log(f.name, f.data.byteLength); // file name & decrypted bytes
}
```

#### `DownloadOptions`

| Field        | Type                                          | Description                                                     |
| ------------ | --------------------------------------------- | --------------------------------------------------------------- |
| `onInfo`     | `(torrent: ParsedTorrent) => void`            | Called once after metadata is decrypted, before download starts |
| `onProgress` | `(downloaded: number, total: number) => void` | Called repeatedly with cumulative bytes downloaded              |
| `signal`     | `AbortSignal?`                                | Cancel the entire download operation                            |

#### `DownloadResult`

| Field   | Type                                   | Description                                  |
| ------- | -------------------------------------- | -------------------------------------------- |
| `name`  | `string`                               | Torrent name (folder name for multi-file)    |
| `files` | `{ name: string; data: Uint8Array }[]` | Decrypted files with names and raw byte data |

### `parseWormholeUrl(url)`

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

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

### Tree-shakeable subpath imports

If you only need upload or download, import from the scoped entry points to avoid bundling unused code:

```ts
// Upload only — download code is never loaded
import { createUploadClient } from "@ziuchen/wormhole-client/upload";
const client = createUploadClient({ timeout: 30_000 });
await client.upload(files, opts);

// Download only — upload code is never loaded
import { createDownloadClient, parseWormholeUrl } from "@ziuchen/wormhole-client/download";
const client = createDownloadClient({ timeout: 30_000 });
await client.download(url, opts);
```

### Node.js file-system helpers (`@ziuchen/wormhole-client/node`)

For reading files from disk or writing downloads to a directory, use the Node.js-specific subpath:

```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); // saved file name & plaintext size in bytes
}
```

## How it works

Files are encrypted locally with AES-128-GCM ([RFC 8188](https://www.rfc-editor.org/rfc/rfc8188) streaming format) before being uploaded to Backblaze B2 through wormhole.app. The encryption key never leaves the client — it lives only in the URL fragment, which is not sent to the server. The recipient's browser (or this CLI) downloads the ciphertext and decrypts it locally.

## License

MIT
