pinata-expo-hooks
Version:
React Native/Expo hooks for Pinata TUS uploads
373 lines (372 loc) • 14 kB
JavaScript
import { useState, useCallback, useRef, useEffect } from "react";
import * as FileSystem from "expo-file-system";
//@ts-nocheck
import { File } from "expo-file-system/next";
import { NetworkError, AuthenticationError, ValidationError, } from "../types";
//@ts-ignore
import Base64 from "Base64";
const BASE_CHUNK_SIZE = 50 * 1024 * 1024 + 1;
export const useUpload = () => {
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [uploadResponse, setUploadResponse] = useState(null);
// Refs to manage pause/resume/cancel functionality
const uploadUrlRef = useRef(null);
const pausedRef = useRef(false);
const cancelledRef = useRef(false);
const uploadOffsetRef = useRef(0);
const fileInfoRef = useRef(null);
const optionsRef = useRef(null);
const networkRef = useRef("public");
const headerRef = useRef({});
const lastResponseHeadersRef = useRef(null);
const chunkSizeRef = useRef(BASE_CHUNK_SIZE);
// Reset state for new upload
const resetState = useCallback(() => {
setProgress(0);
setError(null);
setUploadResponse(null);
uploadUrlRef.current = null;
pausedRef.current = false;
cancelledRef.current = false;
uploadOffsetRef.current = 0;
fileInfoRef.current = null;
}, []);
// Pause upload
const pause = useCallback(() => {
pausedRef.current = true;
}, []);
// Resume upload
const resume = useCallback(() => {
if (pausedRef.current && uploadUrlRef.current && fileInfoRef.current) {
pausedRef.current = false;
continueUpload();
}
}, []);
// Cancel upload
const cancel = useCallback(() => {
cancelledRef.current = true;
setLoading(false);
}, []);
// Helper function to get MIME type from base64 data URL
const getMimeTypeFromDataUrl = (base64String) => {
if (base64String.startsWith("data:")) {
const mimeMatch = base64String.match(/data:([^;]+)/);
return mimeMatch ? mimeMatch[1] : "application/octet-stream";
}
return "application/octet-stream";
};
// Helper function to strip data URL prefix
const stripDataUrlPrefix = (base64String) => {
return base64String.includes(",")
? base64String.split(",")[1]
: base64String;
};
// Continue upload from current offset
const continueUpload = useCallback(async () => {
if (!uploadUrlRef.current || !fileInfoRef.current) {
return;
}
try {
if (cancelledRef.current) {
resetState();
return;
}
if (pausedRef.current) {
return;
}
const { fileUri, base64Data, fileSize, isBase64 } = fileInfoRef.current;
const chunkSize = chunkSizeRef.current;
const offset = uploadOffsetRef.current;
if (offset >= fileSize) {
// Upload is complete, fetch the file info
await finalizeUpload();
return;
}
const endOffset = Math.min(offset + chunkSize, fileSize);
const chunkLength = endOffset - offset;
let bytes;
if (isBase64 && base64Data) {
// For base64 data, we can directly slice the string
// Note: This assumes the entire file is under chunk size
// For larger base64 files, you'd need more complex chunking logic
const binaryString = Base64.atob(base64Data);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
}
else if (fileUri) {
const handle = new File(fileUri).open();
handle.offset = offset;
// Read the chunk at the current offset
bytes = handle.readBytes(chunkLength);
// Close the file handle
handle.close();
}
else {
throw new Error("No file URI or base64 data available");
}
// Upload chunk
const uploadReq = await fetch(uploadUrlRef.current, {
method: "PATCH",
headers: {
"Content-Type": "application/offset+octet-stream",
"Upload-Offset": offset.toString(),
...headerRef.current,
},
body: bytes,
});
lastResponseHeadersRef.current = uploadReq.headers;
if (!uploadReq.ok) {
const errorData = await uploadReq.text();
throw new NetworkError(`HTTP error during chunk upload: ${errorData}`, uploadReq.status, {
error: errorData,
code: "HTTP_ERROR",
metadata: {
requestUrl: uploadReq.url,
},
});
}
// Update offset and progress
const newOffset = offset + chunkLength;
uploadOffsetRef.current = newOffset;
const newProgress = Math.min((newOffset / fileSize) * 100, 99.9);
setProgress(newProgress);
// For base64 uploads under chunk size, we're done after one iteration
if (isBase64 && newOffset >= fileSize) {
await finalizeUpload();
}
else {
// Continue with next chunk for file uploads
continueUpload();
}
}
catch (err) {
if (err instanceof Error) {
setError(err);
}
else {
setError(new Error("Unknown error during upload"));
}
setLoading(false);
}
}, [resetState]);
// Finalize the upload and fetch file info
const finalizeUpload = useCallback(async () => {
if (!uploadUrlRef.current || !fileInfoRef.current) {
return;
}
try {
let cid = "";
// Try to get the CID from the headers of the last response
if (lastResponseHeadersRef.current) {
const uploadCid = lastResponseHeadersRef.current.get("upload-cid");
if (uploadCid) {
cid = uploadCid;
}
else {
cid = null;
}
}
setUploadResponse(cid);
setProgress(100);
setLoading(false);
}
catch (err) {
if (err instanceof Error) {
setError(err);
}
else {
setError(new Error("Unknown error during upload finalization"));
}
setLoading(false);
}
}, []);
// Main upload function for file URIs (existing functionality)
const upload = useCallback(async (fileUri, network, url, options) => {
try {
resetState();
setLoading(true);
// Store references for pause/resume functionality
optionsRef.current = options || null;
networkRef.current = network;
if (options?.chunkSize && options.chunkSize > 0) {
chunkSizeRef.current = options.chunkSize;
}
else {
chunkSizeRef.current = BASE_CHUNK_SIZE;
}
// Get file info from Expo FileSystem
const fileInfo = await FileSystem.getInfoAsync(fileUri);
if (!fileInfo.exists) {
throw new ValidationError(`File does not exist at path: ${fileUri}`);
}
// Basic MIME type mapping
const mimeTypes = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
pdf: "application/pdf",
json: "application/json",
txt: "text/plain",
mp4: "video/mp4",
mov: "video/mov",
mp3: "audio/mpeg",
};
// Determine file type from URI extension
const extension = (fileUri.split(".").pop()?.toLowerCase() ||
"");
let fileType = "application/octet-stream";
if (extension && extension in mimeTypes) {
fileType = mimeTypes[extension];
}
// Get filename from URI
const fileName = options?.name || fileUri.split("/").pop() || "File from SDK";
fileInfoRef.current = {
fileUri,
fileSize: fileInfo.size,
fileType,
fileName,
isBase64: false,
};
await initializeAndStartUpload(network, url, options);
}
catch (err) {
if (err instanceof Error) {
setError(err);
}
else {
setError(new Error("Unknown error during upload initialization"));
}
setLoading(false);
}
}, [resetState]);
// New upload function for base64 strings
const uploadBase64 = useCallback(async (base64String, network, url, options) => {
try {
resetState();
setLoading(true);
// Store references
optionsRef.current = options || null;
networkRef.current = network;
// For base64, we typically don't need chunking, but respect the option
if (options?.chunkSize && options.chunkSize > 0) {
chunkSizeRef.current = options.chunkSize;
}
else {
chunkSizeRef.current = BASE_CHUNK_SIZE;
}
// Clean base64 string and get metadata
const cleanBase64 = stripDataUrlPrefix(base64String);
const fileType = options?.fileType || getMimeTypeFromDataUrl(base64String);
const fileName = options?.fileName || options?.name || "base64-file";
// Calculate file size from base64 string
// Base64 encoding increases size by ~33%, so we need to account for padding
const padding = (cleanBase64.match(/=/g) || []).length;
const fileSize = (cleanBase64.length * 3) / 4 - padding;
// Check if file is under chunk size
if (fileSize > chunkSizeRef.current) {
throw new ValidationError(`Base64 file size (${fileSize} bytes) exceeds chunk size. Use regular upload for larger files.`);
}
fileInfoRef.current = {
base64Data: cleanBase64,
fileSize,
fileType,
fileName,
isBase64: true,
};
await initializeAndStartUpload(network, url, options);
}
catch (err) {
if (err instanceof Error) {
setError(err);
}
else {
setError(new Error("Unknown error during base64 upload initialization"));
}
setLoading(false);
}
}, [resetState]);
// Shared initialization logic
const initializeAndStartUpload = useCallback(async (network, url, options) => {
if (!fileInfoRef.current) {
throw new Error("No file info available");
}
const { fileSize, fileType, fileName } = fileInfoRef.current;
// Set headers
let headers;
if (options?.customHeaders &&
Object.keys(options?.customHeaders).length > 0) {
headers = {
...options.customHeaders,
};
}
else {
headers = {
Source: "sdk/expo",
};
}
headerRef.current = headers;
// Prepare metadata for TUS upload
let metadata = `filename ${Base64.btoa(fileName)},filetype ${Base64.btoa(fileType)},network ${Base64.btoa(network)}`;
if (options?.groupId) {
metadata += `,group_id ${Base64.btoa(options.groupId)}`;
}
if (options?.keyvalues) {
metadata += `,keyvalues ${Base64.btoa(JSON.stringify(options.keyvalues))}`;
}
// Initialize upload with TUS
const urlReq = await fetch(url, {
method: "POST",
headers: {
"Upload-Length": `${fileSize}`,
"Upload-Metadata": metadata,
...headers,
},
});
if (!urlReq.ok) {
const errorData = await urlReq.text();
if (urlReq.status === 401 || urlReq.status === 403) {
throw new AuthenticationError(`Authentication failed: ${errorData}`, urlReq.status, {
error: errorData,
code: "AUTH_ERROR",
});
}
throw new NetworkError("Error initializing upload", urlReq.status, {
error: errorData,
code: "HTTP_ERROR",
});
}
const uploadUrl = urlReq.headers.get("Location");
if (!uploadUrl) {
throw new NetworkError("Upload URL not provided", urlReq.status, {
error: "No location header found",
code: "HTTP_ERROR",
});
}
uploadUrlRef.current = uploadUrl;
// Start the upload process
continueUpload();
}, [continueUpload]);
// Cleanup on unmount
useEffect(() => {
return () => {
cancelledRef.current = true;
};
}, []);
return {
progress,
loading,
error,
uploadResponse,
upload,
uploadBase64,
pause,
resume,
cancel,
resetState,
};
};