TypeScript · 5533 bytes Raw Blame History
1 import { Readable } from 'node:stream';
2 import type { FileItem, DirectoryListing, NodeStatus, NetworkStatus } from '../../shared/types.js';
3
4 export interface ZephyrFSClientOptions {
5 timeout?: number;
6 retries?: number;
7 }
8
9 export interface UploadOptions {
10 encrypted?: boolean;
11 chunkSize?: number;
12 }
13
14 export interface DownloadOptions {
15 range?: { start: number; end?: number };
16 }
17
18 export class ZephyrFSClient {
19 private baseUrl: string;
20 private timeout: number;
21 private retries: number;
22
23 constructor(baseUrl: string, options: ZephyrFSClientOptions = {}) {
24 this.baseUrl = baseUrl.replace(/\/$/, '');
25 this.timeout = options.timeout ?? 30000;
26 this.retries = options.retries ?? 3;
27 }
28
29 async ping(): Promise<boolean> {
30 try {
31 const response = await this.request('GET', '/health');
32 return response.ok;
33 } catch {
34 return false;
35 }
36 }
37
38 async listFiles(path: string = '/'): Promise<DirectoryListing> {
39 const response = await this.request('GET', `/files${path}`, undefined, {
40 'Accept': 'application/json',
41 });
42
43 if (!response.ok) {
44 throw new Error(`Failed to list files: ${response.statusText}`);
45 }
46
47 return response.json();
48 }
49
50 async uploadFile(
51 path: string,
52 filename: string,
53 data: Buffer | Readable,
54 options: UploadOptions = {}
55 ): Promise<{ fileId: string; size: number }> {
56 const formData = new FormData();
57
58 if (Buffer.isBuffer(data)) {
59 formData.append('file', new Blob([data]), filename);
60 } else {
61 // Convert readable stream to blob for web API compatibility
62 const chunks: Uint8Array[] = [];
63 for await (const chunk of data) {
64 chunks.push(chunk);
65 }
66 const buffer = Buffer.concat(chunks);
67 formData.append('file', new Blob([buffer]), filename);
68 }
69
70 formData.append('path', path);
71 if (options.encrypted !== undefined) {
72 formData.append('encrypted', options.encrypted.toString());
73 }
74
75 const response = await this.request('POST', '/files/upload', formData, {
76 'Accept': 'application/json',
77 });
78
79 if (!response.ok) {
80 throw new Error(`Upload failed: ${response.statusText}`);
81 }
82
83 return response.json();
84 }
85
86 async downloadFile(fileId: string, options: DownloadOptions = {}): Promise<{
87 stream: ReadableStream<Uint8Array>;
88 filename: string;
89 size: number;
90 mimeType?: string;
91 }> {
92 const headers: Record<string, string> = {};
93
94 if (options.range) {
95 const { start, end } = options.range;
96 headers['Range'] = `bytes=${start}-${end ?? ''}`;
97 }
98
99 const response = await this.request('GET', `/files/${fileId}/download`, undefined, headers);
100
101 if (!response.ok) {
102 throw new Error(`Download failed: ${response.statusText}`);
103 }
104
105 if (!response.body) {
106 throw new Error('No response body for download');
107 }
108
109 return {
110 stream: response.body,
111 filename: response.headers.get('Content-Disposition')?.split('filename=')[1] || 'unknown',
112 size: parseInt(response.headers.get('Content-Length') || '0'),
113 mimeType: response.headers.get('Content-Type') || undefined,
114 };
115 }
116
117 async deleteFile(fileId: string): Promise<void> {
118 const response = await this.request('DELETE', `/files/${fileId}`);
119
120 if (!response.ok) {
121 throw new Error(`Delete failed: ${response.statusText}`);
122 }
123 }
124
125 async getFileInfo(fileId: string): Promise<FileItem> {
126 const response = await this.request('GET', `/files/${fileId}/info`);
127
128 if (!response.ok) {
129 throw new Error(`Failed to get file info: ${response.statusText}`);
130 }
131
132 return response.json();
133 }
134
135 async getNetworkStatus(): Promise<NetworkStatus> {
136 const response = await this.request('GET', '/status/network');
137
138 if (!response.ok) {
139 throw new Error(`Failed to get network status: ${response.statusText}`);
140 }
141
142 return response.json();
143 }
144
145 async getNodeStatus(): Promise<NodeStatus> {
146 const response = await this.request('GET', '/status/node');
147
148 if (!response.ok) {
149 throw new Error(`Failed to get node status: ${response.statusText}`);
150 }
151
152 return response.json();
153 }
154
155 private async request(
156 method: string,
157 endpoint: string,
158 body?: FormData | string | Buffer,
159 headers: Record<string, string> = {}
160 ): Promise<Response> {
161 const url = `${this.baseUrl}${endpoint}`;
162
163 const requestHeaders = new Headers(headers);
164
165 // Don't set Content-Type for FormData - let browser set it with boundary
166 if (body && !(body instanceof FormData)) {
167 if (typeof body === 'string') {
168 requestHeaders.set('Content-Type', 'application/json');
169 } else {
170 requestHeaders.set('Content-Type', 'application/octet-stream');
171 }
172 }
173
174 let lastError: Error;
175
176 for (let attempt = 0; attempt <= this.retries; attempt++) {
177 try {
178 const controller = new AbortController();
179 const timeoutId = setTimeout(() => controller.abort(), this.timeout);
180
181 const response = await fetch(url, {
182 method,
183 headers: requestHeaders,
184 body,
185 signal: controller.signal,
186 });
187
188 clearTimeout(timeoutId);
189 return response;
190 } catch (error) {
191 lastError = error instanceof Error ? error : new Error(String(error));
192
193 if (attempt < this.retries) {
194 // Exponential backoff
195 await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
196 }
197 }
198 }
199
200 throw lastError!;
201 }
202 }