@uploadista/expo 0.0.3

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/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Expo client for Uploadista
3
+ *
4
+ * This package provides Expo-specific implementations of the Uploadista client services,
5
+ * allowing file uploads through Expo's managed APIs.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { createUploadistaClient } from '@uploadista/expo'
10
+ *
11
+ * const client = createUploadistaClient({
12
+ * baseUrl: 'https://api.example.com',
13
+ * storageId: 'my-storage',
14
+ * chunkSize: 1024 * 1024, // 1MB
15
+ * })
16
+ * ```
17
+ *
18
+ * Advanced usage with custom services:
19
+ * ```ts
20
+ * import { createExpoServices } from '@uploadista/expo/services'
21
+ * import { createUploadistaClientCore } from '@uploadista/client-core'
22
+ *
23
+ * const services = createExpoServices()
24
+ * const client = createUploadistaClientCore({
25
+ * endpoint: 'https://api.example.com',
26
+ * services
27
+ * })
28
+ * ```
29
+ *
30
+ * Legacy usage with FileSystemProvider (backward compatible):
31
+ * ```ts
32
+ * import { ExpoFileSystemProvider } from '@uploadista/expo'
33
+ *
34
+ * const provider = new ExpoFileSystemProvider()
35
+ * const file = await provider.pickImage()
36
+ * ```
37
+ */
38
+
39
+ // Re-export core types from upload-client-core
40
+ export type {
41
+ Base64Service,
42
+ ConnectionPoolConfig,
43
+ FileReaderService,
44
+ FileSource,
45
+ HttpClient,
46
+ HttpRequestOptions,
47
+ HttpResponse,
48
+ IdGenerationService,
49
+ ServiceContainer,
50
+ SliceResult,
51
+ StorageService,
52
+ } from "@uploadista/client-core";
53
+ // Export client factory
54
+ export {
55
+ createUploadistaClient,
56
+ type UploadistaClientOptions,
57
+ } from "./client";
58
+ // Re-export service implementations and factories
59
+ export {
60
+ createAsyncStorageService,
61
+ createExpoBase64Service,
62
+ createExpoFileReaderService,
63
+ createExpoHttpClient,
64
+ createExpoIdGenerationService,
65
+ createExpoServices,
66
+ type ExpoServiceOptions,
67
+ } from "./services";
68
+
69
+ export { ExpoFileSystemProvider } from "./services/expo-file-system-provider";
70
+ // Export Expo-specific types
71
+ export type {
72
+ CameraOptions,
73
+ FileInfo,
74
+ FilePickResult,
75
+ FileSystemProvider,
76
+ PickerOptions,
77
+ } from "./types";
@@ -0,0 +1,32 @@
1
+ import type {
2
+ AbortControllerFactory,
3
+ AbortControllerLike,
4
+ AbortSignalLike,
5
+ } from "@uploadista/client-core";
6
+
7
+ /**
8
+ * Expo AbortController implementation that wraps native AbortController
9
+ * Expo provides an AbortController API that is compatible with the browser AbortController API
10
+ */
11
+ class ExpoAbortController implements AbortControllerLike {
12
+ private native: AbortController;
13
+
14
+ constructor() {
15
+ this.native = new AbortController();
16
+ }
17
+
18
+ get signal(): AbortSignalLike {
19
+ return this.native.signal;
20
+ }
21
+
22
+ abort(_reason?: unknown): void {
23
+ this.native.abort();
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Factory for creating Expo AbortController instances
29
+ */
30
+ export const createExpoAbortControllerFactory = (): AbortControllerFactory => ({
31
+ create: (): AbortControllerLike => new ExpoAbortController(),
32
+ });
@@ -0,0 +1,29 @@
1
+ import type { Base64Service } from "@uploadista/client-core";
2
+ import { fromBase64 as decode, toBase64 as encode } from "js-base64";
3
+
4
+ /**
5
+ * Expo-specific implementation of Base64Service using js-base64 library
6
+ * Expo/React Native doesn't have native btoa/atob functions, so we use js-base64
7
+ */
8
+ export function createExpoBase64Service(): Base64Service {
9
+ return {
10
+ toBase64(data: ArrayBuffer): string {
11
+ // Convert ArrayBuffer to Uint8Array
12
+ const uint8Array = new Uint8Array(data);
13
+ // Convert Uint8Array to string
14
+ const binary = Array.from(uint8Array)
15
+ .map((byte) => String.fromCharCode(byte))
16
+ .join("");
17
+ return encode(binary);
18
+ },
19
+
20
+ fromBase64(data: string): ArrayBuffer {
21
+ const binary = decode(data);
22
+ const uint8Array = new Uint8Array(binary.length);
23
+ for (let i = 0; i < binary.length; i++) {
24
+ uint8Array[i] = binary.charCodeAt(i);
25
+ }
26
+ return uint8Array.buffer;
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,14 @@
1
+ import type { ChecksumService } from "@uploadista/client-core";
2
+ import { computeUint8ArraySha256 } from "../utils/hash-util";
3
+
4
+ /**
5
+ * Creates a ChecksumService for Expo environments
6
+ * Computes SHA-256 checksums of file data using Web Crypto API
7
+ */
8
+ export function createExpoChecksumService(): ChecksumService {
9
+ return {
10
+ computeChecksum: async (data: Uint8Array<ArrayBuffer>) => {
11
+ return computeUint8ArraySha256(data);
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ type ConnectionPoolConfig,
3
+ createInMemoryStorageService,
4
+ type ServiceContainer,
5
+ } from "@uploadista/client-core";
6
+ import type { ReactNativeUploadInput } from "@uploadista/react-native-core";
7
+ import { createExpoAbortControllerFactory } from "./abort-controller-factory";
8
+ import { createExpoBase64Service } from "./base64-service";
9
+ import { createExpoFileReaderService } from "./file-reader-service";
10
+ import { createExpoHttpClient } from "./http-client";
11
+ import { createExpoIdGenerationService } from "./id-generation-service";
12
+ import { createExpoPlatformService } from "./platform-service";
13
+ import { createAsyncStorageService } from "./storage-service";
14
+ import { createExpoWebSocketFactory } from "./websocket-factory";
15
+ import { createExpoChecksumService } from "./checksum-service";
16
+ import { createExpoFingerprintService } from "./fingerprint-service";
17
+
18
+ export interface ExpoServiceOptions {
19
+ /**
20
+ * HTTP client configuration for connection pooling
21
+ */
22
+ connectionPooling?: ConnectionPoolConfig;
23
+
24
+ /**
25
+ * Whether to use AsyncStorage for persistence
26
+ * If false, uses in-memory storage
27
+ * @default true
28
+ */
29
+ useAsyncStorage?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Creates a service container with Expo-specific implementations
34
+ * of all required services for the upload client
35
+ *
36
+ * @param options - Configuration options for Expo services
37
+ * @returns ServiceContainer with Expo implementations
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * import { createExpoServices } from '@uploadista/expo/services';
42
+ *
43
+ * const services = createExpoServices({
44
+ * useAsyncStorage: true,
45
+ * connectionPooling: {
46
+ * maxConnectionsPerHost: 6,
47
+ * connectionTimeout: 30000,
48
+ * }
49
+ * });
50
+ * ```
51
+ */
52
+ export function createExpoServices(
53
+ options: ExpoServiceOptions = {},
54
+ ): ServiceContainer<ReactNativeUploadInput> {
55
+ const { connectionPooling, useAsyncStorage = true } = options;
56
+
57
+ // Create storage service (AsyncStorage or in-memory fallback)
58
+ const storage = useAsyncStorage
59
+ ? createAsyncStorageService()
60
+ : createInMemoryStorageService();
61
+
62
+ // Create other services
63
+ const idGeneration = createExpoIdGenerationService();
64
+ const httpClient = createExpoHttpClient(connectionPooling);
65
+ const fileReader = createExpoFileReaderService();
66
+ const base64 = createExpoBase64Service();
67
+ const websocket = createExpoWebSocketFactory();
68
+ const abortController = createExpoAbortControllerFactory();
69
+ const platform = createExpoPlatformService();
70
+ const checksumService = createExpoChecksumService();
71
+ const fingerprintService = createExpoFingerprintService();
72
+
73
+ return {
74
+ storage,
75
+ idGeneration,
76
+ httpClient,
77
+ fileReader,
78
+ base64,
79
+ websocket,
80
+ abortController,
81
+ platform,
82
+ checksumService,
83
+ fingerprintService,
84
+ };
85
+ }
@@ -0,0 +1,227 @@
1
+ import * as DocumentPicker from "expo-document-picker";
2
+ import * as FileSystem from "expo-file-system";
3
+ import * as ImagePicker from "expo-image-picker";
4
+ import type {
5
+ CameraOptions,
6
+ FileInfo,
7
+ FilePickResult,
8
+ FileSystemProvider,
9
+ PickerOptions,
10
+ } from "../types";
11
+
12
+ /**
13
+ * File system provider implementation for Expo managed environment
14
+ * Uses Expo DocumentPicker, ImagePicker, Camera, and FileSystem APIs
15
+ */
16
+ export class ExpoFileSystemProvider implements FileSystemProvider {
17
+ async pickDocument(options?: PickerOptions): Promise<FilePickResult> {
18
+ try {
19
+ const result = (await DocumentPicker.getDocumentAsync({
20
+ type: options?.allowedTypes || ["*/*"],
21
+ copyToCacheDirectory: true,
22
+ })) as {
23
+ canceled: boolean;
24
+ assets?: Array<{
25
+ uri: string;
26
+ name: string;
27
+ size?: number;
28
+ mimeType?: string;
29
+ }>;
30
+ };
31
+
32
+ if (result.canceled) {
33
+ throw new Error("Document picker was cancelled");
34
+ }
35
+
36
+ const asset = result.assets?.[0];
37
+ if (!asset) {
38
+ throw new Error("No document selected");
39
+ }
40
+
41
+ return {
42
+ uri: asset.uri,
43
+ name: asset.name,
44
+ size: asset.size || 0,
45
+ mimeType: asset.mimeType,
46
+ };
47
+ } catch (error) {
48
+ if (error instanceof Error && error.message.includes("cancelled")) {
49
+ throw error;
50
+ }
51
+ throw new Error(
52
+ `Failed to pick document: ${error instanceof Error ? error.message : String(error)}`,
53
+ );
54
+ }
55
+ }
56
+
57
+ async pickImage(options?: PickerOptions): Promise<FilePickResult> {
58
+ try {
59
+ // Request permissions
60
+ const { status } =
61
+ await ImagePicker.requestMediaLibraryPermissionsAsync();
62
+ if (status !== "granted") {
63
+ throw new Error("Camera roll permission not granted");
64
+ }
65
+
66
+ const result = await ImagePicker.launchImageLibraryAsync({
67
+ // biome-ignore lint/suspicious/noExplicitAny: Expo ImagePicker mediaTypes type compatibility
68
+ mediaTypes: "Images" as any,
69
+ selectionLimit: options?.allowMultiple ? 0 : 1,
70
+ quality: 1,
71
+ });
72
+
73
+ if (result.canceled) {
74
+ throw new Error("Image picker was cancelled");
75
+ }
76
+
77
+ const asset = result.assets?.[0];
78
+ if (!asset) {
79
+ throw new Error("No image selected");
80
+ }
81
+
82
+ return {
83
+ uri: asset.uri,
84
+ name: asset.fileName || `image-${Date.now()}.jpg`,
85
+ size: asset.fileSize || 0,
86
+ mimeType: "image/jpeg",
87
+ };
88
+ } catch (error) {
89
+ if (error instanceof Error && error.message.includes("cancelled")) {
90
+ throw error;
91
+ }
92
+ throw new Error(
93
+ `Failed to pick image: ${error instanceof Error ? error.message : String(error)}`,
94
+ );
95
+ }
96
+ }
97
+
98
+ async pickVideo(options?: PickerOptions): Promise<FilePickResult> {
99
+ try {
100
+ // Request permissions
101
+ const { status } =
102
+ await ImagePicker.requestMediaLibraryPermissionsAsync();
103
+ if (status !== "granted") {
104
+ throw new Error("Camera roll permission not granted");
105
+ }
106
+
107
+ const result = await ImagePicker.launchImageLibraryAsync({
108
+ // biome-ignore lint/suspicious/noExplicitAny: Expo ImagePicker mediaTypes type compatibility
109
+ mediaTypes: "Videos" as any,
110
+ selectionLimit: options?.allowMultiple ? 0 : 1,
111
+ });
112
+
113
+ if (result.canceled) {
114
+ throw new Error("Video picker was cancelled");
115
+ }
116
+
117
+ const asset = result.assets?.[0];
118
+ if (!asset) {
119
+ throw new Error("No video selected");
120
+ }
121
+
122
+ return {
123
+ uri: asset.uri,
124
+ name: asset.fileName || `video-${Date.now()}.mp4`,
125
+ size: asset.fileSize || 0,
126
+ mimeType: "video/mp4",
127
+ };
128
+ } catch (error) {
129
+ if (error instanceof Error && error.message.includes("cancelled")) {
130
+ throw error;
131
+ }
132
+ throw new Error(
133
+ `Failed to pick video: ${error instanceof Error ? error.message : String(error)}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ async pickCamera(options?: CameraOptions): Promise<FilePickResult> {
139
+ try {
140
+ // Request camera permissions
141
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
142
+ if (status !== "granted") {
143
+ throw new Error("Camera permission not granted");
144
+ }
145
+
146
+ const result = await ImagePicker.launchCameraAsync({
147
+ allowsEditing: false,
148
+ aspect: [4, 3],
149
+ quality: options?.quality ?? 1,
150
+ });
151
+
152
+ if (result.canceled) {
153
+ throw new Error("Camera capture was cancelled");
154
+ }
155
+
156
+ const asset = result.assets?.[0];
157
+ if (!asset) {
158
+ throw new Error("No photo captured");
159
+ }
160
+
161
+ return {
162
+ uri: asset.uri,
163
+ name: asset.fileName || `photo-${Date.now()}.jpg`,
164
+ size: asset.fileSize || 0,
165
+ mimeType: "image/jpeg",
166
+ };
167
+ } catch (error) {
168
+ if (error instanceof Error && error.message.includes("cancelled")) {
169
+ throw error;
170
+ }
171
+ throw new Error(
172
+ `Failed to capture photo: ${error instanceof Error ? error.message : String(error)}`,
173
+ );
174
+ }
175
+ }
176
+
177
+ async readFile(uri: string): Promise<ArrayBuffer> {
178
+ try {
179
+ // Read file as base64
180
+ const base64String = await FileSystem.readAsStringAsync(uri, {
181
+ encoding: FileSystem.EncodingType.Base64,
182
+ });
183
+
184
+ // Convert base64 to ArrayBuffer
185
+ // Use js-base64 for decoding since atob is not available in all RN environments
186
+ const { fromBase64 } = await import("js-base64");
187
+ const binaryString = fromBase64(base64String);
188
+ const bytes = new Uint8Array(binaryString.length);
189
+ for (let i = 0; i < binaryString.length; i++) {
190
+ bytes[i] = binaryString.charCodeAt(i);
191
+ }
192
+ return bytes.buffer;
193
+ } catch (error) {
194
+ throw new Error(
195
+ `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
196
+ );
197
+ }
198
+ }
199
+
200
+ async getDocumentUri(filePath: string): Promise<string> {
201
+ // In Expo, the file path is typically already a URI
202
+ return filePath;
203
+ }
204
+
205
+ async getFileInfo(uri: string): Promise<FileInfo> {
206
+ try {
207
+ const fileInfo = await FileSystem.getInfoAsync(uri);
208
+
209
+ if (!fileInfo.exists) {
210
+ throw new Error("File does not exist");
211
+ }
212
+
213
+ return {
214
+ uri,
215
+ name: uri.split("/").pop() || "unknown",
216
+ size: fileInfo.size ?? 0,
217
+ modificationTime: fileInfo.modificationTime
218
+ ? fileInfo.modificationTime * 1000
219
+ : undefined,
220
+ };
221
+ } catch (error) {
222
+ throw new Error(
223
+ `Failed to get file info: ${error instanceof Error ? error.message : String(error)}`,
224
+ );
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,184 @@
1
+ import type {
2
+ FileReaderService,
3
+ FileSource,
4
+ SliceResult,
5
+ } from "@uploadista/client-core";
6
+ import type { ExpoUploadInput } from "@/types/upload-input";
7
+
8
+ /**
9
+ * Expo-specific implementation of FileReaderService
10
+ * Handles Blob, File, and URI-based file inputs using Expo FileSystem APIs
11
+ */
12
+ export function createExpoFileReaderService(): FileReaderService<ExpoUploadInput> {
13
+ return {
14
+ async openFile(input: unknown, _chunkSize: number): Promise<FileSource> {
15
+ // Handle Blob/File objects
16
+ if (input instanceof Blob) {
17
+ return createBlobFileSource(input);
18
+ }
19
+
20
+ // Handle URI strings or URI objects from Expo APIs
21
+ if (
22
+ typeof input === "string" ||
23
+ (input && typeof input === "object" && "uri" in input)
24
+ ) {
25
+ const uri =
26
+ typeof input === "string" ? input : (input as { uri: string }).uri;
27
+ return createExpoUriFileSource(uri);
28
+ }
29
+
30
+ throw new Error(
31
+ "Unsupported file input type for Expo. Expected Blob, File, URI string, or {uri: string}",
32
+ );
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Create a FileSource from a Blob object
39
+ */
40
+ function createBlobFileSource(blob: Blob): FileSource {
41
+ return {
42
+ input: blob,
43
+ size: blob.size,
44
+ async slice(start: number, end: number): Promise<SliceResult> {
45
+ const chunk = blob.slice(start, end);
46
+
47
+ // React Native/Expo Blob may not have arrayBuffer() method
48
+ // Always use FileReader fallback for compatibility
49
+ const arrayBuffer = await blobToArrayBuffer(chunk);
50
+
51
+ const done = end >= blob.size;
52
+
53
+ return {
54
+ done,
55
+ value: new Uint8Array(arrayBuffer),
56
+ size: chunk.size,
57
+ };
58
+ },
59
+ close() {
60
+ // No cleanup needed for Blob
61
+ },
62
+ name: null,
63
+ lastModified: null,
64
+ type: null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Convert Blob to ArrayBuffer using FileReader (fallback for React Native/Expo)
70
+ */
71
+ function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
72
+ return new Promise((resolve, reject) => {
73
+ const reader = new FileReader();
74
+ reader.onload = () => {
75
+ if (reader.result instanceof ArrayBuffer) {
76
+ resolve(reader.result);
77
+ } else {
78
+ reject(new Error("FileReader result is not an ArrayBuffer"));
79
+ }
80
+ };
81
+ reader.onerror = () => reject(reader.error);
82
+ reader.readAsArrayBuffer(blob);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Create a FileSource from a URI using Expo FileSystem
88
+ * This implementation uses expo-file-system for native file access
89
+ */
90
+ function createExpoUriFileSource(uri: string): FileSource {
91
+ // For Expo URIs, we use FileSystem to read the file
92
+ let cachedBlob: Blob | null = null;
93
+ let cachedSize: number | null = null;
94
+
95
+ return {
96
+ input: uri,
97
+ size: cachedSize,
98
+ async slice(start: number, end: number): Promise<SliceResult> {
99
+ // Fetch the blob if not cached
100
+ if (!cachedBlob) {
101
+ try {
102
+ // Use Expo FileSystem to read the file as base64
103
+ const FileSystem = await getExpoFileSystem();
104
+ const fileInfo = await FileSystem.getInfoAsync(uri);
105
+
106
+ if (!fileInfo.exists) {
107
+ throw new Error(`File does not exist at URI: ${uri}`);
108
+ }
109
+
110
+ cachedSize = fileInfo.size ?? 0;
111
+
112
+ // Read the entire file as base64
113
+ const base64String = await FileSystem.readAsStringAsync(uri, {
114
+ encoding: FileSystem.EncodingType.Base64,
115
+ });
116
+
117
+ // Convert base64 to Uint8Array and cache size
118
+ const uint8Array = base64ToUint8Array(base64String);
119
+ cachedSize = uint8Array.length;
120
+
121
+ // Create a Blob from the Uint8Array buffer
122
+ // React Native Blob constructor accepts array-like objects
123
+ // biome-ignore lint/suspicious/noExplicitAny: React Native Blob constructor type compatibility
124
+ cachedBlob = new Blob([uint8Array.buffer] as any);
125
+ } catch (error) {
126
+ throw new Error(`Failed to read file from URI ${uri}: ${error}`);
127
+ }
128
+ }
129
+
130
+ const chunk = cachedBlob.slice(start, end);
131
+
132
+ // React Native/Expo Blob may not have arrayBuffer() method
133
+ // Always use FileReader fallback for compatibility
134
+ const arrayBuffer = await blobToArrayBuffer(chunk);
135
+
136
+ const done = end >= cachedBlob.size;
137
+
138
+ return {
139
+ done,
140
+ value: new Uint8Array(arrayBuffer),
141
+ size: chunk.size,
142
+ };
143
+ },
144
+ close() {
145
+ // Clear cached blob
146
+ cachedBlob = null;
147
+ cachedSize = null;
148
+ },
149
+ name: uri,
150
+ lastModified: null,
151
+ type: null,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Dynamically import Expo FileSystem
157
+ * This allows the service to work even if expo-file-system is not installed
158
+ */
159
+ async function getExpoFileSystem() {
160
+ try {
161
+ return require("expo-file-system");
162
+ } catch (_error) {
163
+ throw new Error(
164
+ "expo-file-system is required but not installed. " +
165
+ "Please install it with: npx expo install expo-file-system",
166
+ );
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Convert base64 string to Uint8Array
172
+ * Uses js-base64 library for cross-platform compatibility
173
+ */
174
+ function base64ToUint8Array(base64: string): Uint8Array {
175
+ // Use js-base64 for decoding (works in all environments)
176
+ const { fromBase64 } = require("js-base64");
177
+ const binaryString = fromBase64(base64);
178
+
179
+ const bytes = new Uint8Array(binaryString.length);
180
+ for (let i = 0; i < binaryString.length; i++) {
181
+ bytes[i] = binaryString.charCodeAt(i);
182
+ }
183
+ return bytes;
184
+ }