diff --git a/.changeset/expo-rn-formdata-uri.md b/.changeset/expo-rn-formdata-uri.md new file mode 100644 index 0000000000..2f6d4712be --- /dev/null +++ b/.changeset/expo-rn-formdata-uri.md @@ -0,0 +1,5 @@ +--- +"@uploadthing/expo": patch +--- + +fix: give expo File uploads a filesystem uri diff --git a/packages/expo/package.json b/packages/expo/package.json index bf1ed24f1c..5eff67f9e8 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -48,6 +48,7 @@ "eslint": "9.25.1", "expo-constants": "^16.0.0", "expo-document-picker": "^12.0.1", + "expo-file-system": "^17.0.1", "expo-image-picker": "^15.0.4", "react": "19.2.2", "react-native": "^0.74.1", @@ -59,6 +60,7 @@ "peerDependencies": { "expo-constants": "*", "expo-document-picker": "*", + "expo-file-system": "*", "expo-image-picker": "*", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-native": "*", diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index d1ff6a81bc..d4bf93f487 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -9,6 +9,7 @@ import type { FileRouter } from "uploadthing/types"; import { peerDependencies } from "../package.json"; import { GENERATE_useDocumentUploader } from "./document-picker"; import { GENERATE_useImageUploader } from "./image-picker"; +import { toRNFormDataFile } from "./rn-formdata"; export interface GenerateTypedHelpersOptions { /** @@ -81,5 +82,37 @@ export const generateReactNativeHelpers = ( const useImageUploader = GENERATE_useImageUploader(opts); const useDocumentUploader = GENERATE_useDocumentUploader(opts); - return { ...vanillaHelpers, useImageUploader, useDocumentUploader }; + const useUploadThing: typeof vanillaHelpers.useUploadThing = ( + endpoint, + hookOpts, + ) => { + return vanillaHelpers.useUploadThing(endpoint, { + ...hookOpts, + // `useUploadThing` applies `onBeforeUploadBegin` inside `startUpload`, + // so convert after the user callback can replace files. + onBeforeUploadBegin: async (files) => { + const maybeReplaced = + (await hookOpts?.onBeforeUploadBegin?.(files)) ?? files; + return Promise.all(maybeReplaced.map(toRNFormDataFile)); + }, + }); + }; + + const uploadFiles: typeof vanillaHelpers.uploadFiles = async ( + slug, + uploadOpts, + ) => { + return vanillaHelpers.uploadFiles(slug, { + ...uploadOpts, + files: await Promise.all(uploadOpts.files.map(toRNFormDataFile)), + }); + }; + + return { + ...vanillaHelpers, + useUploadThing, + uploadFiles, + useImageUploader, + useDocumentUploader, + }; }; diff --git a/packages/expo/src/rn-formdata.ts b/packages/expo/src/rn-formdata.ts new file mode 100644 index 0000000000..9b9bd13b87 --- /dev/null +++ b/packages/expo/src/rn-formdata.ts @@ -0,0 +1,77 @@ +import * as FileSystem from "expo-file-system"; + +const BASE64_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** + * Binary-safe base64 encoder. Reads the buffer as a Uint8Array in 3-byte + * groups (each byte masked to 0-255) so we never go through `btoa`/`Blob` + * APIs that are unreliable with binary data in React Native. + */ +const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let result = ""; + + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const byte1 = (bytes[i] ?? 0) & 0xff; + const byte2 = remaining > 1 ? (bytes[i + 1] ?? 0) & 0xff : 0; + const byte3 = remaining > 2 ? (bytes[i + 2] ?? 0) & 0xff : 0; + + result += BASE64_ALPHABET.charAt(byte1 >> 2); + result += BASE64_ALPHABET.charAt(((byte1 & 0x03) << 4) | (byte2 >> 4)); + result += + remaining > 1 + ? BASE64_ALPHABET.charAt(((byte2 & 0x0f) << 2) | (byte3 >> 6)) + : "="; + result += remaining > 2 ? BASE64_ALPHABET.charAt(byte3 & 0x3f) : "="; + } + + return result; +}; + +/** + * According to React Native's FormData implementation: + * "a "blob", which in React Native just means an object with a uri attribute" + * @see https://github.com/facebook/react-native/blob/030663bb0634fc76f811cdc63e4d09e7ca32f3d4/packages/react-native/Libraries/Network/FormData.js#L78C1-L82C48 + * + * React Native 0.74 File defines `name`/`type` as getter-only, so assigning + * those onto the input File can throw. Return a separate wrapper with own + * `uri`/`type`/`name` whose prototype is the original file (preserving size, + * bytes, and methods). + */ +const wrapRNFormDataFile = ( + file: File, + properties: { uri: string; type: string; name: string }, +): File => Object.setPrototypeOf({ ...properties }, file) as File; + +export const toRNFormDataFile = async (file: File): Promise => { + const type = file.type || "application/octet-stream"; + const name = file.name || "upload"; + const fileWithUri = file as File & { uri?: unknown }; + + if (typeof fileWithUri.uri === "string") { + return wrapRNFormDataFile(file, { + uri: fileWithUri.uri, + type, + name, + }); + } + + const cacheDirectory = FileSystem.cacheDirectory; + if (!cacheDirectory) { + throw new Error("Expo FileSystem cache directory is unavailable"); + } + + // Empty path segments should fall back, so `||` is intentional over `??`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const basename = name.split(/[\\/]/).pop() || "upload"; + const uri = `${cacheDirectory}${Date.now()}-${Math.random()}-${basename}`; + await FileSystem.writeAsStringAsync( + uri, + arrayBufferToBase64(await file.arrayBuffer()), + { encoding: FileSystem.EncodingType.Base64 }, + ); + + return wrapRNFormDataFile(file, { uri, type, name }); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 903248ba83..4b7bb52f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1300,6 +1300,9 @@ importers: expo-document-picker: specifier: ^12.0.1 version: 12.0.2(expo@52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2)) + expo-file-system: + specifier: ^17.0.1 + version: 17.0.1(expo@52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2)) expo-image-picker: specifier: ^15.0.4 version: 15.0.7(expo@52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2)) @@ -32578,6 +32581,10 @@ snapshots: dependencies: expo: 51.0.39(@babel/core@7.27.4)(@babel/preset-env@7.26.0(@babel/core@7.27.4))(encoding@0.1.13) + expo-file-system@17.0.1(expo@52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2)): + dependencies: + expo: 52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2) + expo-file-system@18.0.12(expo@52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2))(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)): dependencies: expo: 52.0.23(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@expo/metro-runtime@4.0.0(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2)))(encoding@0.1.13)(graphql@16.10.0)(react-native@0.74.6(@babel/core@7.29.0)(@babel/preset-env@7.26.0(@babel/core@7.29.0))(@types/react@19.2.7)(encoding@0.1.13)(react@19.2.2))(react@19.2.2)