@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.
@@ -0,0 +1,107 @@
1
+ import type { FingerprintService } from "@uploadista/client-core";
2
+ import * as Crypto from "expo-crypto";
3
+ import type { ExpoUploadInput } from "../types/upload-input";
4
+ import { computeblobSha256 } from "../utils/hash-util";
5
+
6
+ /**
7
+ * Creates a FingerprintService for Expo environments
8
+ * Computes file fingerprints using SHA-256 hashing
9
+ * Supports Blob, File, and URI-based inputs
10
+ */
11
+ export function createExpoFingerprintService(): FingerprintService<ExpoUploadInput> {
12
+ return {
13
+ computeFingerprint: async (input, _endpoint) => {
14
+ // Handle Blob/File objects directly
15
+ if (input instanceof Blob) {
16
+ return computeblobSha256(input);
17
+ }
18
+
19
+ // For URI inputs (string or {uri: string}), we need to convert to Blob first
20
+ if (
21
+ typeof input === "string" ||
22
+ (input && typeof input === "object" && "uri" in input)
23
+ ) {
24
+ const uri =
25
+ typeof input === "string" ? input : (input as { uri: string }).uri;
26
+ return computeFingerprintFromUri(uri);
27
+ }
28
+
29
+ throw new Error(
30
+ "Unsupported file input type for fingerprinting. Expected Blob, File, URI string, or {uri: string}",
31
+ );
32
+ },
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Compute fingerprint from a Expo file URI
38
+ * Uses Expo FileSystem to read the file and compute its SHA-256 hash
39
+ */
40
+ async function computeFingerprintFromUri(uri: string): Promise<string> {
41
+ try {
42
+ // Use Expo FileSystem to read the file as base64
43
+ const FileSystem = await getExpoFileSystem();
44
+ const fileInfo = await FileSystem.getInfoAsync(uri);
45
+
46
+ if (!fileInfo.exists) {
47
+ throw new Error(`File does not exist at URI: ${uri}`);
48
+ }
49
+
50
+ // Read the entire file as base64
51
+ const base64String = await FileSystem.readAsStringAsync(uri, {
52
+ encoding: FileSystem.EncodingType.Base64,
53
+ });
54
+
55
+ // Convert base64 to Uint8Array
56
+ const uint8Array = base64ToUint8Array(base64String);
57
+
58
+ // Compute SHA-256 hash directly on the Uint8Array
59
+ const hashBuffer = await Crypto.digest(
60
+ Crypto.CryptoDigestAlgorithm.SHA256,
61
+ uint8Array,
62
+ );
63
+
64
+ // Convert hash to hex string
65
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
66
+ const hashHex = hashArray
67
+ .map((byte) => byte.toString(16).padStart(2, "0"))
68
+ .join("");
69
+
70
+ return hashHex;
71
+ } catch (error) {
72
+ throw new Error(
73
+ `Failed to compute fingerprint from URI ${uri}: ${error instanceof Error ? error.message : "Unknown error"}`,
74
+ );
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Dynamically import Expo FileSystem
80
+ * This allows the service to work even if expo-file-system is not installed
81
+ */
82
+ async function getExpoFileSystem() {
83
+ try {
84
+ return require("expo-file-system");
85
+ } catch (_error) {
86
+ throw new Error(
87
+ "expo-file-system is required for URI-based fingerprinting. " +
88
+ "Please install it with: npx expo install expo-file-system",
89
+ );
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Convert base64 string to Uint8Array
95
+ * Uses js-base64 library for cross-platform compatibility
96
+ */
97
+ function base64ToUint8Array(base64: string): Uint8Array {
98
+ // Use js-base64 for decoding (works in all environments)
99
+ const { fromBase64 } = require("js-base64");
100
+ const binaryString = fromBase64(base64);
101
+
102
+ const bytes = new Uint8Array(binaryString.length);
103
+ for (let i = 0; i < binaryString.length; i++) {
104
+ bytes[i] = binaryString.charCodeAt(i);
105
+ }
106
+ return bytes;
107
+ }
@@ -0,0 +1,235 @@
1
+ import type {
2
+ ConnectionHealth,
3
+ ConnectionMetrics,
4
+ ConnectionPoolConfig,
5
+ DetailedConnectionMetrics,
6
+ HeadersLike,
7
+ Http2Info,
8
+ HttpClient,
9
+ HttpRequestOptions,
10
+ HttpResponse,
11
+ } from "@uploadista/client-core";
12
+
13
+ /**
14
+ * Expo-specific implementation of HttpClient using fetch API
15
+ * Expo's fetch is similar to browser fetch but optimized for React Native
16
+ */
17
+ export function createExpoHttpClient(
18
+ config?: ConnectionPoolConfig,
19
+ ): HttpClient {
20
+ return new ExpoHttpClient(config);
21
+ }
22
+
23
+ /**
24
+ * Expo HTTP client implementation
25
+ */
26
+ class ExpoHttpClient implements HttpClient {
27
+ private metrics: ConnectionMetrics;
28
+ private connectionTimes: number[] = [];
29
+ private requestCount = 0;
30
+ private errorCount = 0;
31
+ private timeoutCount = 0;
32
+ private retryCount = 0;
33
+ private startTime = Date.now();
34
+
35
+ constructor(_config: ConnectionPoolConfig = {}) {
36
+ // Configuration is stored for potential future use
37
+ // Currently Expo doesn't expose connection pool configuration
38
+
39
+ this.metrics = {
40
+ activeConnections: 0,
41
+ totalConnections: 0,
42
+ reuseRate: 0,
43
+ averageConnectionTime: 0,
44
+ };
45
+ }
46
+
47
+ async request(
48
+ url: string,
49
+ options: HttpRequestOptions = {},
50
+ ): Promise<HttpResponse> {
51
+ this.requestCount++;
52
+
53
+ const fetchOptions: RequestInit = {
54
+ method: options.method || "GET",
55
+ headers: options.headers,
56
+ // biome-ignore lint/suspicious/noExplicitAny: Expo's BodyInit type compatibility
57
+ body: options.body as any,
58
+ signal: options.signal as AbortSignal | undefined,
59
+ };
60
+
61
+ // Add credentials if specified
62
+ if (options.credentials) {
63
+ // biome-ignore lint/suspicious/noExplicitAny: Expo's RequestCredentials type compatibility
64
+ fetchOptions.credentials = options.credentials as any;
65
+ }
66
+
67
+ const startTime = Date.now();
68
+
69
+ try {
70
+ // Handle timeout
71
+ const timeoutPromise = options.timeout
72
+ ? new Promise<never>((_, reject) => {
73
+ setTimeout(() => {
74
+ this.timeoutCount++;
75
+ reject(new Error(`Request timeout after ${options.timeout}ms`));
76
+ }, options.timeout);
77
+ })
78
+ : null;
79
+
80
+ const fetchPromise = fetch(url, fetchOptions);
81
+
82
+ const response = timeoutPromise
83
+ ? await Promise.race([fetchPromise, timeoutPromise])
84
+ : await fetchPromise;
85
+
86
+ const connectionTime = Date.now() - startTime;
87
+ this.connectionTimes.push(connectionTime);
88
+ this.metrics.totalConnections++;
89
+ this.updateMetrics();
90
+
91
+ // Convert fetch Response to HttpResponse
92
+ return this.adaptResponse(response);
93
+ } catch (error) {
94
+ this.errorCount++;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ private adaptResponse(response: Response): HttpResponse {
100
+ const headers: HeadersLike = {
101
+ get: (name: string) => response.headers.get(name),
102
+ has: (name: string) => response.headers.has(name),
103
+ forEach: (callback: (value: string, name: string) => void) => {
104
+ response.headers.forEach(callback);
105
+ },
106
+ };
107
+
108
+ return {
109
+ status: response.status,
110
+ statusText: response.statusText,
111
+ headers,
112
+ ok: response.ok,
113
+ json: () => response.json(),
114
+ text: () => response.text(),
115
+ arrayBuffer: () => response.arrayBuffer(),
116
+ };
117
+ }
118
+
119
+ private updateMetrics(): void {
120
+ if (this.connectionTimes.length > 0) {
121
+ const sum = this.connectionTimes.reduce((a, b) => a + b, 0);
122
+ this.metrics.averageConnectionTime = sum / this.connectionTimes.length;
123
+ }
124
+
125
+ // Estimate reuse rate based on connection times
126
+ // Faster connections are likely reused
127
+ const fastConnections = this.connectionTimes.filter(
128
+ (time) => time < 100,
129
+ ).length;
130
+ this.metrics.reuseRate =
131
+ this.connectionTimes.length > 0
132
+ ? fastConnections / this.connectionTimes.length
133
+ : 0;
134
+ }
135
+
136
+ getMetrics(): ConnectionMetrics {
137
+ return { ...this.metrics };
138
+ }
139
+
140
+ getDetailedMetrics(): DetailedConnectionMetrics {
141
+ const uptime = Date.now() - this.startTime;
142
+ const requestsPerSecond =
143
+ uptime > 0 ? this.requestCount / (uptime / 1000) : 0;
144
+ const errorRate =
145
+ this.requestCount > 0 ? this.errorCount / this.requestCount : 0;
146
+
147
+ const fastConnections = this.connectionTimes.filter(
148
+ (time) => time < 100,
149
+ ).length;
150
+ const slowConnections = this.connectionTimes.length - fastConnections;
151
+
152
+ const health = this.calculateHealth(errorRate);
153
+
154
+ const http2Info: Http2Info = {
155
+ supported: false, // Expo doesn't support HTTP/2
156
+ detected: false,
157
+ version: "h1.1",
158
+ multiplexingActive: false,
159
+ };
160
+
161
+ return {
162
+ ...this.metrics,
163
+ health,
164
+ requestsPerSecond,
165
+ errorRate,
166
+ timeouts: this.timeoutCount,
167
+ retries: this.retryCount,
168
+ fastConnections,
169
+ slowConnections,
170
+ http2Info,
171
+ };
172
+ }
173
+
174
+ private calculateHealth(errorRate: number): ConnectionHealth {
175
+ let status: "healthy" | "degraded" | "poor";
176
+ let score: number;
177
+ const issues: string[] = [];
178
+ const recommendations: string[] = [];
179
+
180
+ if (errorRate > 0.1) {
181
+ status = "poor";
182
+ score = 30;
183
+ issues.push(`High error rate: ${(errorRate * 100).toFixed(1)}%`);
184
+ recommendations.push("Check network connectivity");
185
+ } else if (errorRate > 0.05) {
186
+ status = "degraded";
187
+ score = 60;
188
+ issues.push(`Moderate error rate: ${(errorRate * 100).toFixed(1)}%`);
189
+ recommendations.push("Monitor connection stability");
190
+ } else {
191
+ status = "healthy";
192
+ score = 100;
193
+ }
194
+
195
+ if (this.metrics.averageConnectionTime > 1000) {
196
+ issues.push(
197
+ `Slow connections: ${this.metrics.averageConnectionTime.toFixed(0)}ms avg`,
198
+ );
199
+ recommendations.push("Check network conditions");
200
+ score = Math.min(score, 70);
201
+ }
202
+
203
+ return { status, score, issues, recommendations };
204
+ }
205
+
206
+ reset(): void {
207
+ this.metrics = {
208
+ activeConnections: 0,
209
+ totalConnections: 0,
210
+ reuseRate: 0,
211
+ averageConnectionTime: 0,
212
+ };
213
+ this.connectionTimes = [];
214
+ this.requestCount = 0;
215
+ this.errorCount = 0;
216
+ this.timeoutCount = 0;
217
+ this.retryCount = 0;
218
+ this.startTime = Date.now();
219
+ }
220
+
221
+ async close(): Promise<void> {
222
+ // Expo fetch doesn't require explicit connection closing
223
+ this.reset();
224
+ }
225
+
226
+ async warmupConnections(urls: string[]): Promise<void> {
227
+ // Warmup by making HEAD requests to the URLs
228
+ const promises = urls.map((url) =>
229
+ this.request(url, { method: "HEAD" }).catch(() => {
230
+ // Ignore warmup errors
231
+ }),
232
+ );
233
+ await Promise.all(promises);
234
+ }
235
+ }
@@ -0,0 +1,14 @@
1
+ import type { IdGenerationService } from "@uploadista/client-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+
4
+ /**
5
+ * Expo-specific implementation of IdGenerationService using uuid library
6
+ * crypto.randomUUID() is not available in Expo/React Native, so we use the uuid library
7
+ */
8
+ export function createExpoIdGenerationService(): IdGenerationService {
9
+ return {
10
+ generate(): string {
11
+ return uuidv4();
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Expo service implementations for Uploadista client
3
+ */
4
+
5
+ export { createExpoBase64Service } from "./base64-service";
6
+ export {
7
+ createExpoServices,
8
+ type ExpoServiceOptions,
9
+ } from "./create-expo-services";
10
+ export { createExpoFileReaderService } from "./file-reader-service";
11
+ export { createExpoHttpClient } from "./http-client";
12
+ export { createExpoIdGenerationService } from "./id-generation-service";
13
+ export { createAsyncStorageService } from "./storage-service";
@@ -0,0 +1,71 @@
1
+ import type { PlatformService, Timeout } from "@uploadista/client-core";
2
+
3
+ /**
4
+ * Expo implementation of PlatformService
5
+ */
6
+ export function createExpoPlatformService(): PlatformService {
7
+ return {
8
+ setTimeout: (callback: () => void, ms: number | undefined) => {
9
+ return globalThis.setTimeout(callback, ms);
10
+ },
11
+
12
+ clearTimeout: (id: Timeout) => {
13
+ globalThis.clearTimeout(id as number);
14
+ },
15
+
16
+ isBrowser: () => {
17
+ return false;
18
+ },
19
+
20
+ isOnline: () => {
21
+ // Expo's NetInfo would need to be imported separately
22
+ // For now, assume online
23
+ return true;
24
+ },
25
+
26
+ isFileLike: (value: unknown) => {
27
+ // Check for blob-like interface or File-like object
28
+ return (
29
+ value !== null &&
30
+ typeof value === "object" &&
31
+ ("uri" in value || "name" in value)
32
+ );
33
+ },
34
+
35
+ getFileName: (file: unknown) => {
36
+ if (file !== null && typeof file === "object" && "name" in file) {
37
+ return (file as Record<string, unknown>).name as string | undefined;
38
+ }
39
+ if (file !== null && typeof file === "object" && "uri" in file) {
40
+ const uri = (file as Record<string, unknown>).uri as string | undefined;
41
+ if (uri) {
42
+ return uri.split("/").pop();
43
+ }
44
+ }
45
+ return undefined;
46
+ },
47
+
48
+ getFileType: (file: unknown) => {
49
+ if (file !== null && typeof file === "object" && "type" in file) {
50
+ return (file as Record<string, unknown>).type as string | undefined;
51
+ }
52
+ return undefined;
53
+ },
54
+
55
+ getFileSize: (file: unknown) => {
56
+ if (file !== null && typeof file === "object" && "size" in file) {
57
+ return (file as Record<string, unknown>).size as number | undefined;
58
+ }
59
+ return undefined;
60
+ },
61
+
62
+ getFileLastModified: (file: unknown) => {
63
+ if (file !== null && typeof file === "object" && "lastModified" in file) {
64
+ return (file as Record<string, unknown>).lastModified as
65
+ | number
66
+ | undefined;
67
+ }
68
+ return undefined;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,62 @@
1
+ import AsyncStorage from "@react-native-async-storage/async-storage";
2
+ import type { StorageService } from "@uploadista/client-core";
3
+ /**
4
+ * Expo-specific implementation of StorageService using AsyncStorage
5
+ * AsyncStorage is provided as an optional peer dependency and must be installed separately
6
+ */
7
+ export function createAsyncStorageService(): StorageService {
8
+ const findEntries = async (
9
+ prefix: string,
10
+ ): Promise<Record<string, string>> => {
11
+ const results: Record<string, string> = {};
12
+
13
+ const keys = await AsyncStorage.getAllKeys();
14
+ for (const key in keys) {
15
+ if (key.startsWith(prefix)) {
16
+ const item = await AsyncStorage.getItem(key);
17
+ if (item) {
18
+ results[key] = item;
19
+ }
20
+ }
21
+ }
22
+
23
+ return results;
24
+ };
25
+
26
+ return {
27
+ async getItem(key: string): Promise<string | null> {
28
+ try {
29
+ return await AsyncStorage.getItem(key);
30
+ } catch (error) {
31
+ console.error(`AsyncStorage getItem error for key ${key}:`, error);
32
+ return null;
33
+ }
34
+ },
35
+
36
+ async setItem(key: string, value: string): Promise<void> {
37
+ try {
38
+ await AsyncStorage.setItem(key, value);
39
+ } catch (error) {
40
+ console.error(`AsyncStorage setItem error for key ${key}:`, error);
41
+ throw error;
42
+ }
43
+ },
44
+
45
+ async removeItem(key: string): Promise<void> {
46
+ try {
47
+ await AsyncStorage.removeItem(key);
48
+ } catch (error) {
49
+ console.error(`AsyncStorage removeItem error for key ${key}:`, error);
50
+ throw error;
51
+ }
52
+ },
53
+
54
+ async findAll(): Promise<Record<string, string>> {
55
+ return findEntries("");
56
+ },
57
+
58
+ async find(prefix: string): Promise<Record<string, string>> {
59
+ return findEntries(prefix);
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,62 @@
1
+ import type { WebSocketFactory, WebSocketLike } from "@uploadista/client-core";
2
+
3
+ /**
4
+ * Expo WebSocket implementation that wraps native WebSocket
5
+ * Expo provides a WebSocket API that is compatible with the browser WebSocket API
6
+ */
7
+ class ExpoWebSocket implements WebSocketLike {
8
+ readonly CONNECTING = 0;
9
+ readonly OPEN = 1;
10
+ readonly CLOSING = 2;
11
+ readonly CLOSED = 3;
12
+
13
+ readyState: number;
14
+ onopen: (() => void) | null = null;
15
+ onclose: ((event: { code: number; reason: string }) => void) | null = null;
16
+ onerror: ((event: { message: string }) => void) | null = null;
17
+ onmessage: ((event: { data: string }) => void) | null = null;
18
+
19
+ private native: WebSocket;
20
+
21
+ constructor(url: string) {
22
+ this.native = new WebSocket(url);
23
+ this.readyState = this.native.readyState;
24
+
25
+ // Proxy event handlers
26
+ this.native.onopen = () => {
27
+ this.readyState = this.native.readyState;
28
+ this.onopen?.();
29
+ };
30
+
31
+ this.native.onclose = (event) => {
32
+ this.readyState = this.native.readyState;
33
+ this.onclose?.({
34
+ code: event.code ?? 1000,
35
+ reason: event.reason ?? "undefined reason",
36
+ });
37
+ };
38
+
39
+ this.native.onerror = (event) => {
40
+ this.onerror?.(event);
41
+ };
42
+
43
+ this.native.onmessage = (event) => {
44
+ this.onmessage?.({ data: event.data });
45
+ };
46
+ }
47
+
48
+ send(data: string | Uint8Array): void {
49
+ this.native.send(data);
50
+ }
51
+
52
+ close(code?: number, reason?: string): void {
53
+ this.native.close(code, reason);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Factory for creating Expo WebSocket connections
59
+ */
60
+ export const createExpoWebSocketFactory = (): WebSocketFactory => ({
61
+ create: (url: string): WebSocketLike => new ExpoWebSocket(url),
62
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Expo file input types
3
+ * Can be a Blob, File, URI string, or URI object from Expo APIs
4
+ */
5
+ export type ExpoUploadInput = Blob | File | string | { uri: string };
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Core types for Expo Uploadista client
3
+ */
4
+
5
+ /**
6
+ * Options for file picker operations
7
+ */
8
+ export interface PickerOptions {
9
+ /** Allowed file types/MIME types */
10
+ allowedTypes?: string[];
11
+ /** Allow multiple selection */
12
+ allowMultiple?: boolean;
13
+ /** Maximum file size in bytes */
14
+ maxSize?: number;
15
+ }
16
+
17
+ /**
18
+ * Options for camera operations
19
+ */
20
+ export interface CameraOptions {
21
+ /** Camera to use: 'front' or 'back' */
22
+ cameraType?: "front" | "back";
23
+ /** Image quality (0-1) */
24
+ quality?: number;
25
+ /** Maximum width for captured image */
26
+ maxWidth?: number;
27
+ /** Maximum height for captured image */
28
+ maxHeight?: number;
29
+ }
30
+
31
+ /**
32
+ * Result from a file pick operation
33
+ */
34
+ export interface FilePickResult {
35
+ /** URI to the file (platform-specific format) */
36
+ uri: string;
37
+ /** File name with extension */
38
+ name: string;
39
+ /** File size in bytes */
40
+ size: number;
41
+ /** MIME type of the file (if available) */
42
+ mimeType?: string;
43
+ /** Local file path (if available) */
44
+ localPath?: string;
45
+ }
46
+
47
+ /**
48
+ * Information about a file
49
+ */
50
+ export interface FileInfo {
51
+ /** URI to the file */
52
+ uri: string;
53
+ /** File name */
54
+ name: string;
55
+ /** File size in bytes */
56
+ size: number;
57
+ /** MIME type (if available) */
58
+ mimeType?: string;
59
+ /** Last modified timestamp */
60
+ modificationTime?: number;
61
+ }
62
+
63
+ /**
64
+ * Interface for file system abstraction layer
65
+ * Provides pluggable access to file system APIs across different Expo environments
66
+ */
67
+ export interface FileSystemProvider {
68
+ /**
69
+ * Opens a document picker for selecting files
70
+ * @param options - Configuration for the picker
71
+ * @returns Promise resolving to picked file information
72
+ */
73
+ pickDocument(options?: PickerOptions): Promise<FilePickResult>;
74
+
75
+ /**
76
+ * Opens an image picker for selecting images from gallery
77
+ * @param options - Configuration for the picker
78
+ * @returns Promise resolving to picked image information
79
+ */
80
+ pickImage(options?: PickerOptions): Promise<FilePickResult>;
81
+
82
+ /**
83
+ * Opens a video picker for selecting videos from gallery
84
+ * @param options - Configuration for the picker
85
+ * @returns Promise resolving to picked video information
86
+ */
87
+ pickVideo(options?: PickerOptions): Promise<FilePickResult>;
88
+
89
+ /**
90
+ * Captures a photo using the device camera
91
+ * @param options - Configuration for camera
92
+ * @returns Promise resolving to captured photo information
93
+ */
94
+ pickCamera(options?: CameraOptions): Promise<FilePickResult>;
95
+
96
+ /**
97
+ * Gets a URI for a document that can be read
98
+ * @param filePath - Path to the document
99
+ * @returns Promise resolving to accessible URI
100
+ */
101
+ getDocumentUri(filePath: string): Promise<string>;
102
+
103
+ /**
104
+ * Reads file contents as ArrayBuffer
105
+ * @param uri - URI to read from
106
+ * @returns Promise resolving to file contents as ArrayBuffer
107
+ */
108
+ readFile(uri: string): Promise<ArrayBuffer>;
109
+
110
+ /**
111
+ * Gets information about a file
112
+ * @param uri - URI of the file
113
+ * @returns Promise resolving to file information
114
+ */
115
+ getFileInfo(uri: string): Promise<FileInfo>;
116
+ }