@vee3/upload 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vee3/upload",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Upload local files to Vee3.",
5
5
  "homepage": "https://vee3.io",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { UploadError, describeNetworkError } from "./errors.js";
2
+
1
3
  export const DEFAULT_API_BASE_URL = "https://api.vee3.io";
2
4
 
3
5
  async function formatApiError(response) {
@@ -15,19 +17,26 @@ async function formatApiError(response) {
15
17
  }
16
18
 
17
19
  export async function resolveUpload({ apiBaseUrl, uploadCode, contentType }) {
18
- const response = await fetch(`${apiBaseUrl.replace(/\/$/, "")}/v1/agent-uploads/resolve`, {
19
- method: "POST",
20
- headers: {
21
- "Content-Type": "application/json"
22
- },
23
- body: JSON.stringify({
24
- upload_code: uploadCode,
25
- content_type: contentType
26
- })
27
- });
20
+ const requestUrl = `${apiBaseUrl.replace(/\/$/, "")}/v1/agent-uploads/resolve`;
21
+
22
+ let response;
23
+ try {
24
+ response = await fetch(requestUrl, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json"
28
+ },
29
+ body: JSON.stringify({
30
+ upload_code: uploadCode,
31
+ content_type: contentType
32
+ })
33
+ });
34
+ } catch (error) {
35
+ throw new UploadError(describeNetworkError(error, `the Vee3 API at ${apiBaseUrl}`));
36
+ }
28
37
 
29
38
  if (!response.ok) {
30
- throw new Error(await formatApiError(response));
39
+ throw new UploadError(await formatApiError(response));
31
40
  }
32
41
 
33
42
  return await response.json();
package/src/errors.js ADDED
@@ -0,0 +1,51 @@
1
+ export class UsageError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "UsageError";
5
+ }
6
+ }
7
+
8
+ export class UploadError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "UploadError";
12
+ }
13
+ }
14
+
15
+ const TLS_VERIFICATION_CODES = new Set([
16
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
17
+ "SELF_SIGNED_CERT_IN_CHAIN",
18
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
19
+ "CERT_HAS_EXPIRED",
20
+ "ERR_TLS_CERT_ALTNAME_INVALID"
21
+ ]);
22
+
23
+ /**
24
+ * Turns a low-level fetch failure into a message a user can act on. Node's
25
+ * fetch throws a generic "fetch failed" TypeError and hides the real reason in
26
+ * error.cause, so we surface the underlying code here.
27
+ */
28
+ export function describeNetworkError(error, targetDescription) {
29
+ const cause = error?.cause ?? error;
30
+ const code = cause?.code;
31
+
32
+ if (code === "ECONNREFUSED") {
33
+ return `Could not connect to ${targetDescription}. Make sure it is running and the address is correct.`;
34
+ }
35
+ if (code === "ENOTFOUND") {
36
+ return `Could not find ${targetDescription}. Check the address and your network connection.`;
37
+ }
38
+ if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
39
+ return `Connection to ${targetDescription} timed out.`;
40
+ }
41
+ if (code && TLS_VERIFICATION_CODES.has(code)) {
42
+ return (
43
+ `Could not verify the TLS certificate for ${targetDescription}. ` +
44
+ "This usually means a proxy or antivirus is inspecting HTTPS traffic. " +
45
+ "Use Node 22.15 or newer, or set NODE_EXTRA_CA_CERTS to your root certificate."
46
+ );
47
+ }
48
+
49
+ const detail = cause?.message ?? error?.message ?? String(error);
50
+ return `Request to ${targetDescription} failed: ${detail}`;
51
+ }
package/src/format.js ADDED
@@ -0,0 +1,17 @@
1
+ const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"];
2
+
3
+ export function formatBytes(byteCount) {
4
+ if (!Number.isFinite(byteCount) || byteCount <= 0) {
5
+ return "0 B";
6
+ }
7
+
8
+ let unitIndex = 0;
9
+ let value = byteCount;
10
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
11
+ value /= 1024;
12
+ unitIndex += 1;
13
+ }
14
+
15
+ const decimals = unitIndex === 0 ? 0 : 1;
16
+ return `${value.toFixed(decimals)} ${BYTE_UNITS[unitIndex]}`;
17
+ }
package/src/index.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { stat } from "node:fs/promises";
4
+ import { basename } from "node:path";
4
5
  import { DEFAULT_API_BASE_URL, resolveUpload } from "./api.js";
5
6
  import { detectContentType } from "./detectContentType.js";
7
+ import { UploadError, UsageError } from "./errors.js";
8
+ import { formatBytes } from "./format.js";
6
9
  import { ensureSystemCertificateTrust } from "./systemCertificates.js";
7
10
  import { uploadFileToSignedUrl } from "./uploadFile.js";
8
11
 
9
12
  ensureSystemCertificateTrust();
10
13
 
11
14
  function printUsage() {
12
- console.error(
13
- "Usage: vee3-upload <upload_code> <file_path> [--api-base-url https://api.vee3.io] [--json]"
14
- );
15
+ console.error("Usage: vee3-upload <upload_code> <file_path>");
15
16
  }
16
17
 
17
18
  function parseArguments(argumentsList) {
@@ -24,7 +25,7 @@ function parseArguments(argumentsList) {
24
25
  if (argument === "--api-base-url") {
25
26
  const nextArgument = argumentsList[index + 1];
26
27
  if (!nextArgument) {
27
- throw new Error("--api-base-url requires a value");
28
+ throw new UsageError("--api-base-url requires a value");
28
29
  }
29
30
  apiBaseUrl = nextArgument;
30
31
  index += 1;
@@ -38,7 +39,7 @@ function parseArguments(argumentsList) {
38
39
  }
39
40
 
40
41
  if (positionalArguments.length !== 2) {
41
- throw new Error("Expected upload_code and file_path");
42
+ throw new UsageError("Expected upload_code and file_path");
42
43
  }
43
44
 
44
45
  return {
@@ -49,16 +50,30 @@ function parseArguments(argumentsList) {
49
50
  };
50
51
  }
51
52
 
53
+ async function readFileStat(filePath) {
54
+ try {
55
+ return await stat(filePath);
56
+ } catch (error) {
57
+ if (error?.code === "ENOENT") {
58
+ throw new UploadError(`File not found: ${filePath}`);
59
+ }
60
+ throw new UploadError(`Could not read file: ${filePath} (${error.message})`);
61
+ }
62
+ }
63
+
52
64
  async function main() {
53
65
  const { uploadCode, filePath, apiBaseUrl, outputJson } = parseArguments(
54
66
  process.argv.slice(2)
55
67
  );
56
- const fileStat = await stat(filePath);
68
+
69
+ const fileStat = await readFileStat(filePath);
57
70
  if (!fileStat.isFile()) {
58
- throw new Error(`File path is not a file: ${filePath}`);
71
+ throw new UploadError(`Not a file: ${filePath}`);
59
72
  }
60
73
 
61
74
  const contentType = await detectContentType(filePath);
75
+
76
+ console.error("Resolving upload code...");
62
77
  const resolvedUpload = await resolveUpload({
63
78
  apiBaseUrl,
64
79
  uploadCode,
@@ -66,11 +81,14 @@ async function main() {
66
81
  });
67
82
 
68
83
  if (fileStat.size > resolvedUpload.max_bytes) {
69
- throw new Error(
70
- `File is too large: ${fileStat.size} bytes exceeds ${resolvedUpload.max_bytes} bytes`
84
+ throw new UploadError(
85
+ `File is too large: ${formatBytes(fileStat.size)} exceeds the ${formatBytes(
86
+ resolvedUpload.max_bytes
87
+ )} limit.`
71
88
  );
72
89
  }
73
90
 
91
+ console.error(`Uploading ${basename(filePath)} (${formatBytes(fileStat.size)})...`);
74
92
  await uploadFileToSignedUrl({
75
93
  uploadUrl: resolvedUpload.upload_url,
76
94
  filePath,
@@ -93,7 +111,11 @@ async function main() {
93
111
  }
94
112
 
95
113
  main().catch((error) => {
96
- printUsage();
97
- console.error(error instanceof Error ? error.message : String(error));
114
+ if (error instanceof UsageError) {
115
+ printUsage();
116
+ console.error(error.message);
117
+ } else {
118
+ console.error(error instanceof Error ? error.message : String(error));
119
+ }
98
120
  process.exit(1);
99
121
  });
@@ -0,0 +1,59 @@
1
+ import { formatBytes } from "./format.js";
2
+
3
+ const TTY_RENDER_INTERVAL_MS = 150;
4
+ const NON_TTY_MILESTONE_STEP = 20;
5
+
6
+ /**
7
+ * Reports upload progress to stderr so that stdout stays clean for the
8
+ * upload_id. In an interactive terminal it redraws a single line; in
9
+ * non-interactive contexts (such as agent logs) it prints discrete milestone
10
+ * lines to avoid carriage-return spam.
11
+ */
12
+ export function createUploadProgressReporter(totalBytes) {
13
+ const isInteractive = Boolean(process.stderr.isTTY);
14
+ let lastRenderTime = 0;
15
+ let lastMilestone = 0;
16
+
17
+ function computePercent(uploadedBytes) {
18
+ if (totalBytes <= 0) {
19
+ return 100;
20
+ }
21
+ return Math.min(100, Math.floor((uploadedBytes / totalBytes) * 100));
22
+ }
23
+
24
+ function report(uploadedBytes) {
25
+ const percent = computePercent(uploadedBytes);
26
+
27
+ if (isInteractive) {
28
+ const now = Date.now();
29
+ const isComplete = uploadedBytes >= totalBytes;
30
+ if (!isComplete && now - lastRenderTime < TTY_RENDER_INTERVAL_MS) {
31
+ return;
32
+ }
33
+ lastRenderTime = now;
34
+ process.stderr.write(
35
+ `\rUploading ${percent}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})`
36
+ );
37
+ return;
38
+ }
39
+
40
+ const milestone = Math.floor(percent / NON_TTY_MILESTONE_STEP) * NON_TTY_MILESTONE_STEP;
41
+ if (milestone <= lastMilestone || milestone >= 100) {
42
+ return;
43
+ }
44
+ lastMilestone = milestone;
45
+ process.stderr.write(
46
+ `Uploading ${milestone}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})\n`
47
+ );
48
+ }
49
+
50
+ function finish() {
51
+ if (isInteractive) {
52
+ process.stderr.write(`\rUploaded ${formatBytes(totalBytes)} (100%)\n`);
53
+ return;
54
+ }
55
+ process.stderr.write(`Uploaded ${formatBytes(totalBytes)} (100%)\n`);
56
+ }
57
+
58
+ return { report, finish };
59
+ }
package/src/uploadFile.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { createReadStream } from "node:fs";
2
+ import { Transform } from "node:stream";
3
+ import { UploadError, describeNetworkError } from "./errors.js";
4
+ import { createUploadProgressReporter } from "./progress.js";
2
5
 
3
6
  export async function uploadFileToSignedUrl({
4
7
  uploadUrl,
@@ -6,20 +9,44 @@ export async function uploadFileToSignedUrl({
6
9
  fileSizeBytes,
7
10
  requiredHeaders
8
11
  }) {
12
+ const progressReporter = createUploadProgressReporter(fileSizeBytes);
13
+ let uploadedBytes = 0;
14
+
15
+ const progressStream = new Transform({
16
+ transform(chunk, _encoding, callback) {
17
+ uploadedBytes += chunk.length;
18
+ progressReporter.report(uploadedBytes);
19
+ callback(null, chunk);
20
+ }
21
+ });
22
+
23
+ const fileStream = createReadStream(filePath);
24
+ fileStream.on("error", (error) => {
25
+ progressStream.destroy(error);
26
+ });
27
+ fileStream.pipe(progressStream);
28
+
9
29
  const headers = {
10
30
  ...requiredHeaders,
11
31
  "Content-Length": String(fileSizeBytes)
12
32
  };
13
33
 
14
- const response = await fetch(uploadUrl, {
15
- method: "PUT",
16
- headers,
17
- body: createReadStream(filePath),
18
- duplex: "half"
19
- });
34
+ let response;
35
+ try {
36
+ response = await fetch(uploadUrl, {
37
+ method: "PUT",
38
+ headers,
39
+ body: progressStream,
40
+ duplex: "half"
41
+ });
42
+ } catch (error) {
43
+ throw new UploadError(describeNetworkError(error, "Vee3 storage"));
44
+ }
20
45
 
21
46
  if (!response.ok) {
22
- const responseText = await response.text();
23
- throw new Error(responseText || `Upload failed with HTTP ${response.status}`);
47
+ const responseText = (await response.text()).trim();
48
+ throw new UploadError(responseText || `Upload failed with HTTP ${response.status}`);
24
49
  }
50
+
51
+ progressReporter.finish();
25
52
  }