bytifi 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,13 +4,15 @@ Official command-line tool for encrypting and uploading files to [Bytifi](https:
4
4
 
5
5
  ## Install
6
6
 
7
+ ### npm (all platforms)
8
+
7
9
  ```bash
8
10
  npm install -g bytifi
9
11
  ```
10
12
 
11
13
  Requires **Node.js 18+**.
12
14
 
13
- Or install from source:
15
+ Or from source:
14
16
 
15
17
  ```bash
16
18
  git clone https://github.com/jpwcguy/Bytifi.git
@@ -18,22 +20,51 @@ cd Bytifi
18
20
  npm link
19
21
  ```
20
22
 
21
- ## Setup
23
+ ### Windows (WinGet)
24
+
25
+ ```powershell
26
+ winget install Bytifi.Bytifi
27
+ ```
28
+
29
+ Or download [bytifi.exe from GitHub Releases](https://github.com/jpwcguy/Bytifi/releases/latest).
30
+
31
+ ### Environment variable (all platforms)
22
32
 
23
33
  ```bash
24
34
  export BYTIFI_API_KEY=usk_your_api_key_here
25
35
  ```
26
36
 
37
+ PowerShell:
38
+
39
+ ```powershell
40
+ $env:BYTIFI_API_KEY = "usk_your_api_key_here"
41
+ ```
42
+
27
43
  Create an API key in **Account → API** on bytifi.com.
28
44
 
45
+ ## Setup
46
+
47
+ Set your API key via environment variable (see Install above) or pass `--api-key` per command.
48
+ Prefer the environment variable — keys on the command line can appear in shell history and process lists.
49
+
29
50
  ## Usage
30
51
 
31
52
  ```bash
32
53
  bytifi upload ./photo.png
54
+ bytifi upload ./photo.png --api-key usk_your_api_key_here
33
55
  bytifi upload ./photo.png --expires 60 --json
34
56
  bytifi upload ./large.iso -q
35
57
  ```
36
58
 
59
+ Without a global install:
60
+
61
+ ```bash
62
+ npx bytifi upload ./photo.png --api-key usk_your_api_key_here
63
+ npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
64
+ ```
65
+
66
+ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
67
+
37
68
  ### Options
38
69
 
39
70
  | Flag | Description |
@@ -43,22 +74,24 @@ bytifi upload ./large.iso -q
43
74
  | `--delete-on-download` | Delete after first download |
44
75
  | `--json` | Machine-readable JSON output |
45
76
  | `-q, --quiet` | Print only the share URL |
46
- | `--mime-type` | Override MIME type detection |
77
+ | `--verbose` | Print API error details to stderr |
78
+ | `--mime-type` | Override detected MIME type |
79
+ | `--base-url` | API base URL (default: `https://bytifi.com`) |
80
+
81
+ Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
82
+
83
+ JSON output (`--json`) includes `shareUrl`, `downloadUrl`, `encryptionToken`, `expiresAt`, and `token`.
84
+
85
+ Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
47
86
 
48
87
  ## How it works
49
88
 
50
89
  1. Encrypts the file locally with AES-GCM (same format as the website)
51
- 2. Uploads encrypted bytes via the public API
90
+ 2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
52
91
  3. Prints a share URL including `#token=...`
53
92
 
54
- The server never receives plaintext or the decryption token.
55
-
56
93
  ## Development
57
94
 
58
95
  ```bash
59
96
  node bin/bytifi.js upload ./file.png --json
60
97
  ```
61
-
62
- ## Status
63
-
64
- WIP — v0.1.0 direct + multipart upload supported.
package/bin/bytifi.js CHANGED
@@ -3,33 +3,50 @@
3
3
  import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
- import { fileURLToPath } from 'node:url'
6
+ import { createRequire } from 'node:module'
7
7
  import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
8
8
 
9
- const __filename = fileURLToPath(import.meta.url)
9
+ const require = createRequire(import.meta.url)
10
+ const { version } = require('../package.json')
10
11
 
11
12
  function printHelp() {
12
- process.stdout.write(`Bytifi CLI — encrypt and upload files
13
+ process.stdout.write(`Bytifi CLI v${version} — encrypt and upload files
13
14
 
14
15
  Usage:
15
16
  bytifi upload <file> [options]
16
17
 
17
18
  Options:
18
- -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
19
- -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
19
+ -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
20
+ -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
20
21
  --delete-on-download Remove file after first download
21
22
  --json Print machine-readable JSON to stdout
22
23
  -q, --quiet Print only the share URL
24
+ --verbose Show API error details on stderr
23
25
  --mime-type <type> Override detected MIME type
24
26
  --base-url <url> API base URL (default: https://bytifi.com)
27
+ -V, --version Show version
25
28
  -h, --help Show this help
26
29
 
30
+ Exit codes:
31
+ 0 success
32
+ 1 usage or validation error
33
+ 2 API error (4xx/5xx response)
34
+ 3 network error
35
+
27
36
  Examples:
28
37
  bytifi upload ./photo.png
29
38
  BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
30
39
  `)
31
40
  }
32
41
 
42
+ function readFlagValue(argv, index, flagName) {
43
+ const value = argv[index + 1]
44
+ if (!value || value.startsWith('-')) {
45
+ throw new Error(`Option ${flagName} requires a value.`)
46
+ }
47
+ return value
48
+ }
49
+
33
50
  function parseArgs(argv) {
34
51
  const positional = []
35
52
  const options = {
@@ -38,9 +55,11 @@ function parseArgs(argv) {
38
55
  deleteOnDownload: false,
39
56
  json: false,
40
57
  quiet: false,
58
+ verbose: false,
41
59
  mimeType: '',
42
60
  baseUrl: 'https://bytifi.com',
43
61
  help: false,
62
+ version: false,
44
63
  }
45
64
 
46
65
  for (let index = 0; index < argv.length; index += 1) {
@@ -51,6 +70,11 @@ function parseArgs(argv) {
51
70
  continue
52
71
  }
53
72
 
73
+ if (arg === '--version' || arg === '-V') {
74
+ options.version = true
75
+ continue
76
+ }
77
+
54
78
  if (arg === '--json') {
55
79
  options.json = true
56
80
  continue
@@ -61,31 +85,41 @@ function parseArgs(argv) {
61
85
  continue
62
86
  }
63
87
 
88
+ if (arg === '--verbose') {
89
+ options.verbose = true
90
+ continue
91
+ }
92
+
64
93
  if (arg === '--delete-on-download') {
65
94
  options.deleteOnDownload = true
66
95
  continue
67
96
  }
68
97
 
69
98
  if (arg === '--api-key' || arg === '-k') {
70
- options.apiKey = argv[index + 1] || ''
99
+ options.apiKey = readFlagValue(argv, index, arg)
71
100
  index += 1
72
101
  continue
73
102
  }
74
103
 
75
104
  if (arg === '--expires' || arg === '-e') {
76
- options.expiresInMinutes = Number(argv[index + 1])
105
+ const raw = readFlagValue(argv, index, arg)
106
+ const minutes = Number(raw)
107
+ if (!Number.isFinite(minutes)) {
108
+ throw new Error(`Invalid expires value: ${raw}`)
109
+ }
110
+ options.expiresInMinutes = minutes
77
111
  index += 1
78
112
  continue
79
113
  }
80
114
 
81
115
  if (arg === '--mime-type') {
82
- options.mimeType = argv[index + 1] || ''
116
+ options.mimeType = readFlagValue(argv, index, arg)
83
117
  index += 1
84
118
  continue
85
119
  }
86
120
 
87
121
  if (arg === '--base-url') {
88
- options.baseUrl = argv[index + 1] || options.baseUrl
122
+ options.baseUrl = readFlagValue(argv, index, arg)
89
123
  index += 1
90
124
  continue
91
125
  }
@@ -107,6 +141,14 @@ function validateExpires(minutes) {
107
141
  }
108
142
  }
109
143
 
144
+ function writeProgress(percent) {
145
+ process.stderr.write(`\rEncrypting and uploading: ${percent}%`)
146
+ }
147
+
148
+ function clearProgressLine() {
149
+ process.stderr.write('\r\x1b[K')
150
+ }
151
+
110
152
  async function runUpload(filePath, options) {
111
153
  validateExpires(options.expiresInMinutes)
112
154
 
@@ -115,34 +157,64 @@ async function runUpload(filePath, options) {
115
157
  }
116
158
 
117
159
  const resolvedPath = path.resolve(filePath)
118
- await fs.access(resolvedPath)
119
-
120
- const result = await uploadFile(resolvedPath, {
121
- apiKey: options.apiKey,
122
- baseUrl: options.baseUrl,
123
- expiresInMinutes: options.expiresInMinutes,
124
- deleteOnDownload: options.deleteOnDownload,
125
- mimeType: options.mimeType || undefined,
126
- onProgress: options.quiet || options.json
127
- ? undefined
128
- : (percent) => {
129
- process.stderr.write(`Uploading encrypted parts: ${percent}%\n`)
130
- },
131
- })
132
-
133
- if (options.json) {
134
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
135
- return
136
- }
137
-
138
- if (options.quiet) {
139
- process.stdout.write(`${result.shareUrl}\n`)
140
- return
141
- }
142
-
143
- process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
144
- process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
145
- process.stdout.write(`Expires: ${result.expiresAt}\n`)
160
+
161
+ try {
162
+ await fs.access(resolvedPath)
163
+ } catch {
164
+ throw new Error(`File not found or not readable: ${resolvedPath}`)
165
+ }
166
+
167
+ const abortController = new AbortController()
168
+
169
+ const handleSignal = () => {
170
+ abortController.abort()
171
+ }
172
+
173
+ process.on('SIGINT', handleSignal)
174
+ process.on('SIGTERM', handleSignal)
175
+
176
+ const showProgress = !options.quiet && !options.json
177
+ let lastPercent = -1
178
+
179
+ try {
180
+ const result = await uploadFile(resolvedPath, {
181
+ apiKey: options.apiKey,
182
+ baseUrl: options.baseUrl,
183
+ expiresInMinutes: options.expiresInMinutes,
184
+ deleteOnDownload: options.deleteOnDownload,
185
+ mimeType: options.mimeType || undefined,
186
+ signal: abortController.signal,
187
+ onProgress: showProgress
188
+ ? (percent) => {
189
+ if (percent !== lastPercent) {
190
+ lastPercent = percent
191
+ writeProgress(percent)
192
+ }
193
+ }
194
+ : undefined,
195
+ })
196
+
197
+ if (showProgress) {
198
+ clearProgressLine()
199
+ }
200
+
201
+ if (options.json) {
202
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
203
+ return
204
+ }
205
+
206
+ if (options.quiet) {
207
+ process.stdout.write(`${result.shareUrl}\n`)
208
+ return
209
+ }
210
+
211
+ process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
212
+ process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
213
+ process.stdout.write(`Expires: ${result.expiresAt}\n`)
214
+ } finally {
215
+ process.off('SIGINT', handleSignal)
216
+ process.off('SIGTERM', handleSignal)
217
+ }
146
218
  }
147
219
 
148
220
  function exitCodeForError(error) {
@@ -151,6 +223,25 @@ function exitCodeForError(error) {
151
223
  return 1
152
224
  }
153
225
 
226
+ function printError(error, verbose) {
227
+ process.stderr.write(`${error.message || 'Upload failed.'}\n`)
228
+
229
+ if (!verbose) return
230
+
231
+ if (error instanceof BytifiApiError) {
232
+ if (error.status) {
233
+ process.stderr.write(`HTTP ${error.status}\n`)
234
+ }
235
+ if (error.body) {
236
+ process.stderr.write(`${JSON.stringify(error.body, null, 2)}\n`)
237
+ }
238
+ }
239
+
240
+ if (error instanceof BytifiNetworkError && error.cause) {
241
+ process.stderr.write(`${error.cause}\n`)
242
+ }
243
+ }
244
+
154
245
  async function main() {
155
246
  const [command, ...rest] = process.argv.slice(2)
156
247
 
@@ -159,6 +250,11 @@ async function main() {
159
250
  process.exit(0)
160
251
  }
161
252
 
253
+ if (command === '--version' || command === '-V') {
254
+ process.stdout.write(`${version}\n`)
255
+ process.exit(0)
256
+ }
257
+
162
258
  if (command !== 'upload') {
163
259
  if (command === 'help') {
164
260
  printHelp()
@@ -175,15 +271,30 @@ async function main() {
175
271
  process.exit(0)
176
272
  }
177
273
 
274
+ if (options.version) {
275
+ process.stdout.write(`${version}\n`)
276
+ process.exit(0)
277
+ }
278
+
279
+ if (options.json && options.quiet) {
280
+ throw new Error('Use either --json or --quiet, not both.')
281
+ }
282
+
178
283
  const filePath = positional[0]
179
284
  if (!filePath) {
180
285
  throw new Error('Missing file path. Usage: bytifi upload <file>')
181
286
  }
182
287
 
288
+ if (positional.length > 1) {
289
+ process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
290
+ }
291
+
183
292
  await runUpload(filePath, options)
184
293
  }
185
294
 
186
295
  main().catch((error) => {
187
- process.stderr.write(`${error.message || 'Upload failed.'}\n`)
296
+ clearProgressLine()
297
+ const verbose = process.argv.includes('--verbose')
298
+ printError(error, verbose)
188
299
  process.exit(exitCodeForError(error))
189
300
  })
package/lib/crypto.js CHANGED
@@ -22,17 +22,6 @@ export function encryptChunk(plainChunk, key, noncePrefix, chunkIndex) {
22
22
  return Buffer.concat([encrypted, tag])
23
23
  }
24
24
 
25
- export function createEncryptionToken(tokenBytes = crypto.randomBytes(32)) {
26
- if (tokenBytes.length !== 32) {
27
- throw new Error('Encryption token must be 32 bytes.')
28
- }
29
-
30
- return {
31
- tokenBytes,
32
- token: toBase64Url(tokenBytes),
33
- }
34
- }
35
-
36
25
  export function buildClientEncryptionMeta({
37
26
  plainChunkSize,
38
27
  chunkCount,
@@ -51,62 +40,143 @@ export function buildClientEncryptionMeta({
51
40
  }
52
41
  }
53
42
 
54
- export async function encryptFileBuffer(fileBuffer, {
43
+ export function calculateEncryptedSize(originalSize, plainChunkSize = PLAIN_CHUNK_SIZE) {
44
+ const chunkCount = Math.ceil(originalSize / plainChunkSize) || 1
45
+ let encryptedSize = 0
46
+
47
+ for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
48
+ const start = chunkIndex * plainChunkSize
49
+ const plainSize = Math.min(originalSize - start, plainChunkSize)
50
+ encryptedSize += plainSize + 16
51
+ }
52
+
53
+ return { chunkCount, encryptedSize }
54
+ }
55
+
56
+ export function createEncryptionContext({
57
+ originalSize,
55
58
  originalName = 'upload',
56
59
  mimeType = 'application/octet-stream',
57
60
  tokenBytes = crypto.randomBytes(32),
58
61
  noncePrefix = crypto.randomBytes(8),
59
62
  plainChunkSize = PLAIN_CHUNK_SIZE,
60
- } = {}) {
63
+ }) {
61
64
  if (tokenBytes.length !== 32) throw new Error('Encryption token must be 32 bytes.')
62
65
  if (noncePrefix.length !== 8) throw new Error('Nonce prefix must be 8 bytes.')
63
66
 
64
- const key = tokenBytes
65
- const chunkCount = Math.ceil(fileBuffer.length / plainChunkSize) || 1
66
- const encryptedParts = []
67
- let encryptedSize = 0
68
-
69
- for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
70
- const start = chunkIndex * plainChunkSize
71
- const end = Math.min(fileBuffer.length, start + plainChunkSize)
72
- const plainChunk = fileBuffer.subarray(start, end)
73
- const encryptedChunk = encryptChunk(plainChunk, key, noncePrefix, chunkIndex)
74
- encryptedParts.push(encryptedChunk)
75
- encryptedSize += encryptedChunk.length
76
- }
77
-
67
+ const { chunkCount, encryptedSize } = calculateEncryptedSize(originalSize, plainChunkSize)
78
68
  const meta = buildClientEncryptionMeta({
79
69
  plainChunkSize,
80
70
  chunkCount,
81
71
  noncePrefix,
82
- originalSize: fileBuffer.length,
72
+ originalSize,
83
73
  mimeType,
84
74
  })
85
75
 
86
76
  return {
87
77
  token: toBase64Url(tokenBytes),
88
78
  tokenBytes,
79
+ noncePrefix,
89
80
  meta,
90
- encryptedParts,
91
- encryptedBuffer: Buffer.concat(encryptedParts),
81
+ chunkCount,
92
82
  encryptedSize,
93
83
  originalName,
94
84
  mimeType,
95
- originalSize: fileBuffer.length,
85
+ originalSize,
86
+ plainChunkSize,
96
87
  }
97
88
  }
98
89
 
99
- export async function encryptFile(filePath, options = {}) {
90
+ export async function resolveUploadFile(filePath, options = {}) {
100
91
  const absolutePath = path.resolve(filePath)
101
- const fileBuffer = await fs.readFile(absolutePath)
92
+ const stat = await fs.stat(absolutePath)
93
+
94
+ if (!stat.isFile()) {
95
+ throw new Error('Upload path must be a file.')
96
+ }
97
+
102
98
  const originalName = options.originalName || path.basename(absolutePath)
103
99
  const mimeType = options.mimeType || guessMimeType(originalName)
104
100
 
105
- return encryptFileBuffer(fileBuffer, {
106
- ...options,
101
+ return {
102
+ absolutePath,
103
+ context: createEncryptionContext({
104
+ originalSize: stat.size,
105
+ originalName,
106
+ mimeType,
107
+ }),
108
+ }
109
+ }
110
+
111
+ async function readPlainChunk(fileHandle, chunkIndex, originalSize, plainChunkSize = PLAIN_CHUNK_SIZE) {
112
+ const start = chunkIndex * plainChunkSize
113
+ const length = Math.min(plainChunkSize, originalSize - start)
114
+ const buffer = Buffer.alloc(length)
115
+ const { bytesRead } = await fileHandle.read(buffer, 0, length, start)
116
+
117
+ if (bytesRead !== length) {
118
+ throw new Error('File ended before the upload was complete.')
119
+ }
120
+
121
+ return buffer
122
+ }
123
+
124
+ export async function encryptChunkFromFile(fileHandle, chunkIndex, context) {
125
+ const plainChunk = await readPlainChunk(
126
+ fileHandle,
127
+ chunkIndex,
128
+ context.originalSize,
129
+ context.plainChunkSize,
130
+ )
131
+
132
+ return encryptChunk(plainChunk, context.tokenBytes, context.noncePrefix, chunkIndex)
133
+ }
134
+
135
+ export async function collectEncryptedParts(filePath, context) {
136
+ const fileHandle = await fs.open(filePath, 'r')
137
+ const encryptedParts = []
138
+
139
+ try {
140
+ for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
141
+ encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
142
+ }
143
+ } finally {
144
+ await fileHandle.close()
145
+ }
146
+
147
+ return encryptedParts
148
+ }
149
+
150
+ export async function encryptFileBuffer(fileBuffer, {
151
+ originalName = 'upload',
152
+ mimeType = 'application/octet-stream',
153
+ tokenBytes = crypto.randomBytes(32),
154
+ noncePrefix = crypto.randomBytes(8),
155
+ plainChunkSize = PLAIN_CHUNK_SIZE,
156
+ } = {}) {
157
+ const context = createEncryptionContext({
158
+ originalSize: fileBuffer.length,
107
159
  originalName,
108
160
  mimeType,
161
+ tokenBytes,
162
+ noncePrefix,
163
+ plainChunkSize,
109
164
  })
165
+
166
+ const encryptedParts = []
167
+
168
+ for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
169
+ const start = chunkIndex * plainChunkSize
170
+ const end = Math.min(fileBuffer.length, start + plainChunkSize)
171
+ const plainChunk = fileBuffer.subarray(start, end)
172
+ encryptedParts.push(encryptChunk(plainChunk, context.tokenBytes, context.noncePrefix, chunkIndex))
173
+ }
174
+
175
+ return {
176
+ ...context,
177
+ encryptedParts,
178
+ encryptedBuffer: Buffer.concat(encryptedParts),
179
+ }
110
180
  }
111
181
 
112
182
  export function decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunkIndex) {
package/lib/upload.js CHANGED
@@ -1,6 +1,15 @@
1
- import { DIRECT_UPLOAD_LIMIT_BYTES } from './crypto.js'
1
+ import {
2
+ DIRECT_UPLOAD_LIMIT_BYTES,
3
+ encryptChunkFromFile,
4
+ resolveUploadFile,
5
+ } from './crypto.js'
6
+ import fs from 'node:fs/promises'
2
7
 
3
8
  const DEFAULT_BASE_URL = 'https://bytifi.com'
9
+ const DEFAULT_CONCURRENCY = 4
10
+ const POLL_INTERVAL_MS = 1500
11
+ const POLL_TIMEOUT_MS = 30 * 60 * 1000
12
+ const MAX_RETRIES = 3
4
13
 
5
14
  export class BytifiApiError extends Error {
6
15
  constructor(message, { status = 0, body = null } = {}) {
@@ -19,10 +28,22 @@ export class BytifiNetworkError extends Error {
19
28
  }
20
29
  }
21
30
 
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms))
33
+ }
34
+
22
35
  function normalizeBaseUrl(baseUrl) {
23
36
  return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
24
37
  }
25
38
 
39
+ function isRetryableError(error) {
40
+ if (error instanceof BytifiNetworkError) return true
41
+ if (error instanceof BytifiApiError) {
42
+ return error.status === 429 || error.status >= 500
43
+ }
44
+ return false
45
+ }
46
+
26
47
  async function readResponseBody(response) {
27
48
  const text = await response.text()
28
49
  if (!text) return null
@@ -34,7 +55,7 @@ async function readResponseBody(response) {
34
55
  }
35
56
  }
36
57
 
37
- async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
58
+ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
38
59
  const url = `${normalizeBaseUrl(baseUrl)}${path}`
39
60
  const requestHeaders = {
40
61
  Authorization: `Bearer ${apiKey}`,
@@ -48,8 +69,12 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
48
69
  method,
49
70
  headers: requestHeaders,
50
71
  body,
72
+ signal,
51
73
  })
52
74
  } catch (error) {
75
+ if (signal?.aborted) {
76
+ throw new Error('Upload aborted.')
77
+ }
53
78
  throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
54
79
  }
55
80
 
@@ -67,18 +92,41 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
67
92
  return payload
68
93
  }
69
94
 
95
+ async function apiFetchWithRetry(baseUrl, path, options = {}) {
96
+ const { retries = MAX_RETRIES, signal } = options
97
+
98
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
99
+ try {
100
+ return await apiFetch(baseUrl, path, options)
101
+ } catch (error) {
102
+ if (signal?.aborted || error.message === 'Upload aborted.') {
103
+ throw error
104
+ }
105
+
106
+ if (!isRetryableError(error) || attempt === retries) {
107
+ throw error
108
+ }
109
+
110
+ const delayMs = Math.min(1000 * (2 ** attempt), 8000)
111
+ await sleep(delayMs)
112
+ }
113
+ }
114
+
115
+ throw new Error('Request failed after retries.')
116
+ }
117
+
70
118
  function buildShareUrl(payload, encryptionToken) {
71
119
  return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
72
120
  }
73
121
 
74
- function buildResult(payload, encryption, shareUrl) {
122
+ function buildResult(payload, context, shareUrl) {
75
123
  return {
76
124
  shareUrl,
77
125
  url: payload.url,
78
126
  downloadUrl: payload.downloadUrl,
79
127
  token: payload.token,
80
- encryptionToken: encryption.token,
81
- originalName: payload.originalName || encryption.originalName,
128
+ encryptionToken: context.token,
129
+ originalName: payload.originalName || context.originalName,
82
130
  size: payload.size,
83
131
  expiresAt: payload.expiresAt,
84
132
  deleteOnDownload: payload.deleteOnDownload,
@@ -86,157 +134,202 @@ function buildResult(payload, encryption, shareUrl) {
86
134
  }
87
135
  }
88
136
 
89
- async function uploadDirect(encryption, {
137
+ async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
138
+ const fileHandle = await fs.open(filePath, 'r')
139
+ const encryptedParts = []
140
+
141
+ try {
142
+ for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
143
+ if (signal?.aborted) {
144
+ throw new Error('Upload aborted.')
145
+ }
146
+
147
+ encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
148
+ onProgress?.(Math.round(((chunkIndex + 1) / context.chunkCount) * 90))
149
+ }
150
+ } finally {
151
+ await fileHandle.close()
152
+ }
153
+
154
+ onProgress?.(95)
155
+ return Buffer.concat(encryptedParts)
156
+ }
157
+
158
+ async function uploadDirect(context, encryptedBuffer, {
90
159
  apiKey,
91
160
  baseUrl,
92
161
  expiresInMinutes,
93
162
  deleteOnDownload,
163
+ onProgress,
164
+ signal,
94
165
  }) {
95
166
  const formData = new FormData()
96
- const blob = new Blob([encryption.encryptedBuffer], { type: 'application/octet-stream' })
167
+ const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
97
168
 
98
- formData.append('file', blob, encryption.originalName)
169
+ formData.append('file', blob, context.originalName)
99
170
  formData.append('clientEncrypted', 'true')
100
- formData.append('clientEncryptionMeta', JSON.stringify(encryption.meta))
171
+ formData.append('clientEncryptionMeta', JSON.stringify(context.meta))
101
172
  formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
102
173
  formData.append('expiresInMinutes', String(expiresInMinutes))
103
174
 
104
- const payload = await apiFetch(baseUrl, '/api/public/upload', {
175
+ const payload = await apiFetchWithRetry(baseUrl, '/api/public/upload', {
105
176
  apiKey,
106
177
  method: 'POST',
107
178
  body: formData,
179
+ signal,
108
180
  })
109
181
 
110
- const shareUrl = buildShareUrl(payload, encryption.token)
111
- return buildResult(payload, encryption, shareUrl)
182
+ onProgress?.(100)
183
+
184
+ const shareUrl = buildShareUrl(payload, context.token)
185
+ return buildResult(payload, context, shareUrl)
112
186
  }
113
187
 
114
188
  async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
189
+ const startedAt = Date.now()
190
+
115
191
  while (true) {
116
192
  if (signal?.aborted) {
117
193
  throw new Error('Upload aborted.')
118
194
  }
119
195
 
120
- const payload = await apiFetch(
196
+ if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
197
+ throw new Error('Upload finalization timed out. Try again later.')
198
+ }
199
+
200
+ const payload = await apiFetchWithRetry(
121
201
  baseUrl,
122
202
  `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
123
- { apiKey },
203
+ { apiKey, signal },
124
204
  )
125
205
 
126
- if (payload.status !== 'processing') {
206
+ if (payload.status !== 'processing' && payload.status !== 'pending') {
127
207
  return payload
128
208
  }
129
209
 
130
- await new Promise((resolve) => setTimeout(resolve, 1500))
210
+ await sleep(POLL_INTERVAL_MS)
131
211
  }
132
212
  }
133
213
 
134
- async function uploadMultipart(encryption, {
214
+ async function uploadMultipartStreaming(filePath, context, {
135
215
  apiKey,
136
216
  baseUrl,
137
217
  expiresInMinutes,
138
218
  deleteOnDownload,
139
219
  onProgress,
140
220
  signal,
141
- concurrency = 3,
221
+ concurrency = DEFAULT_CONCURRENCY,
142
222
  }) {
143
- const partSize = encryption.meta.chunkSize + 16
144
- const initPayload = await apiFetch(baseUrl, '/api/public/upload/init', {
223
+ const partSize = context.meta.chunkSize + 16
224
+ const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
145
225
  apiKey,
146
226
  method: 'POST',
147
227
  headers: { 'Content-Type': 'application/json' },
148
228
  body: JSON.stringify({
149
- originalName: encryption.originalName,
150
- mimeType: encryption.mimeType,
151
- size: encryption.encryptedSize,
152
- originalSize: encryption.originalSize,
229
+ originalName: context.originalName,
230
+ mimeType: context.mimeType,
231
+ size: context.encryptedSize,
232
+ originalSize: context.originalSize,
153
233
  clientEncrypted: true,
154
- clientEncryptionMeta: encryption.meta,
234
+ clientEncryptionMeta: context.meta,
155
235
  partSize,
156
236
  expiresInMinutes,
157
237
  deleteOnDownload,
158
238
  }),
239
+ signal,
159
240
  })
160
241
 
161
242
  const sessionToken = initPayload.sessionToken
162
- const totalParts = encryption.encryptedParts.length
243
+ const workerCount = Math.min(
244
+ Number(initPayload.concurrency) || concurrency,
245
+ context.chunkCount,
246
+ )
247
+ const fileHandle = await fs.open(filePath, 'r')
248
+ let nextChunkIndex = 0
163
249
  let completedParts = 0
164
250
 
165
- const uploadPart = async (partNumber) => {
166
- const partBuffer = encryption.encryptedParts[partNumber - 1]
167
- await apiFetch(
168
- baseUrl,
169
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
170
- {
171
- apiKey,
172
- method: 'PUT',
173
- headers: { 'Content-Type': 'application/octet-stream' },
174
- body: partBuffer,
175
- },
176
- )
177
-
178
- completedParts += 1
179
- onProgress?.(Math.round((completedParts / totalParts) * 100))
180
- }
181
-
182
- const queue = Array.from({ length: totalParts }, (_, index) => index + 1)
183
- const workers = Array.from({ length: Math.min(concurrency, totalParts) }, async () => {
184
- while (queue.length > 0) {
251
+ async function uploadWorker() {
252
+ while (true) {
185
253
  if (signal?.aborted) {
186
254
  throw new Error('Upload aborted.')
187
255
  }
188
256
 
189
- const partNumber = queue.shift()
190
- if (!partNumber) return
191
- await uploadPart(partNumber)
257
+ const chunkIndex = nextChunkIndex
258
+ nextChunkIndex += 1
259
+
260
+ if (chunkIndex >= context.chunkCount) {
261
+ return
262
+ }
263
+
264
+ const partNumber = chunkIndex + 1
265
+ const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
266
+
267
+ await apiFetchWithRetry(
268
+ baseUrl,
269
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
270
+ {
271
+ apiKey,
272
+ method: 'PUT',
273
+ headers: { 'Content-Type': 'application/octet-stream' },
274
+ body: encryptedPart,
275
+ signal,
276
+ },
277
+ )
278
+
279
+ completedParts += 1
280
+ onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
192
281
  }
193
- })
282
+ }
194
283
 
195
284
  try {
196
- await Promise.all(workers)
285
+ await Promise.all(Array.from({ length: workerCount }, () => uploadWorker()))
197
286
  } catch (error) {
198
- await apiFetch(baseUrl, '/api/public/upload/abort', {
287
+ await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
199
288
  apiKey,
200
289
  method: 'POST',
201
290
  headers: { 'Content-Type': 'application/json' },
202
291
  body: JSON.stringify({ sessionToken }),
292
+ signal,
203
293
  }).catch(() => {})
204
294
 
205
295
  throw error
296
+ } finally {
297
+ await fileHandle.close()
206
298
  }
207
299
 
208
- let completePayload = await apiFetch(baseUrl, '/api/public/upload/complete', {
300
+ let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
209
301
  apiKey,
210
302
  method: 'POST',
211
303
  headers: { 'Content-Type': 'application/json' },
212
304
  body: JSON.stringify({ sessionToken }),
305
+ signal,
213
306
  })
214
307
 
215
- if (completePayload.status === 'processing') {
308
+ if (completePayload.status === 'processing' || completePayload.status === 'pending') {
216
309
  completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
217
310
  }
218
311
 
219
- const shareUrl = buildShareUrl(completePayload, encryption.token)
220
- return buildResult(completePayload, encryption, shareUrl)
312
+ const shareUrl = buildShareUrl(completePayload, context.token)
313
+ return buildResult(completePayload, context, shareUrl)
221
314
  }
222
315
 
223
- export async function uploadEncrypted(encryption, options) {
316
+ export async function uploadFile(filePath, options) {
224
317
  if (!options?.apiKey) {
225
318
  throw new Error('API key is required.')
226
319
  }
227
320
 
228
- if (encryption.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
229
- return uploadDirect(encryption, options)
230
- }
321
+ const { absolutePath, context } = await resolveUploadFile(filePath, {
322
+ mimeType: options.mimeType,
323
+ })
231
324
 
232
- return uploadMultipart(encryption, options)
233
- }
325
+ if (context.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
326
+ const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
327
+ onProgress: options.onProgress,
328
+ signal: options.signal,
329
+ })
234
330
 
235
- export async function uploadFile(filePath, options) {
236
- const { encryptFile } = await import('./crypto.js')
237
- const encryption = await encryptFile(filePath, {
238
- mimeType: options?.mimeType,
239
- })
331
+ return uploadDirect(context, encryptedBuffer, options)
332
+ }
240
333
 
241
- return uploadEncrypted(encryption, options)
334
+ return uploadMultipartStreaming(absolutePath, context, options)
242
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Official Bytifi CLI — encrypt and upload files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "scripts": {
17
- "upload": "node bin/bytifi.js upload"
17
+ "upload": "node bin/bytifi.js upload",
18
+ "build:bundle": "esbuild bin/bytifi.js --bundle --platform=node --format=cjs --outfile=dist/bytifi-bundle.cjs",
19
+ "build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip"
18
20
  },
19
21
  "repository": {
20
22
  "type": "git",
@@ -34,5 +36,9 @@
34
36
  "upload",
35
37
  "file-sharing"
36
38
  ],
37
- "license": "MIT"
39
+ "license": "MIT",
40
+ "devDependencies": {
41
+ "@yao-pkg/pkg": "^6.21.0",
42
+ "esbuild": "^0.25.0"
43
+ }
38
44
  }