@telestack/storage 1.0.0
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/README.md +132 -0
- package/debug-fetch.js +28 -0
- package/dist/index.d.mts +329 -0
- package/dist/index.d.ts +329 -0
- package/dist/index.global.js +1 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +37 -0
- package/src/BatchBuilder.ts +48 -0
- package/src/CryptoHelper.ts +72 -0
- package/src/HttpClient.ts +152 -0
- package/src/ImageHelper.ts +80 -0
- package/src/QueryBuilder.ts +45 -0
- package/src/StorageRef.ts +153 -0
- package/src/TelestackStorage.ts +93 -0
- package/src/UploadTask.ts +332 -0
- package/src/index.ts +28 -0
- package/src/types.ts +91 -0
- package/test-e2e.js +142 -0
- package/test-e2e.ts +182 -0
- package/test-output.txt +0 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { UploadState, UploadTaskSnapshot, Observer, UploadOptions, UploadResult, FileMetadata } from './types';
|
|
2
|
+
import { HttpClient } from './HttpClient';
|
|
3
|
+
import { ImageHelper } from './ImageHelper';
|
|
4
|
+
import { CryptoHelper } from './CryptoHelper';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A cancellable, resumable, and observable upload task.
|
|
8
|
+
* Designed directly after Firebase Storage's UploadTask but optimized for Telestack resumable multipart pipelines.
|
|
9
|
+
*/
|
|
10
|
+
export class UploadTask implements Promise<UploadResult> {
|
|
11
|
+
private _state: UploadState = 'processing';
|
|
12
|
+
private _bytesTransferred = 0;
|
|
13
|
+
private _totalBytes: number = 0;
|
|
14
|
+
private _data: Blob;
|
|
15
|
+
private _observers: Observer<UploadTaskSnapshot>[] = [];
|
|
16
|
+
private _promise: Promise<UploadResult>;
|
|
17
|
+
private _resolve!: (res: UploadResult) => void;
|
|
18
|
+
private _reject!: (err: Error) => void;
|
|
19
|
+
|
|
20
|
+
// Resumable internals
|
|
21
|
+
private _uploadId?: string;
|
|
22
|
+
private _parts: { PartNumber: number; ETag: string }[] = [];
|
|
23
|
+
private _currentPartIndex = 0;
|
|
24
|
+
private _activeXhr?: XMLHttpRequest;
|
|
25
|
+
private _isResumable = false;
|
|
26
|
+
|
|
27
|
+
// Simple upload internals
|
|
28
|
+
private _simpleFileMetadata?: FileMetadata;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly client: HttpClient,
|
|
32
|
+
private readonly path: string,
|
|
33
|
+
data: Blob,
|
|
34
|
+
private readonly name: string,
|
|
35
|
+
private readonly contentType: string,
|
|
36
|
+
private readonly options: UploadOptions,
|
|
37
|
+
) {
|
|
38
|
+
this._data = data;
|
|
39
|
+
this._totalBytes = data.size;
|
|
40
|
+
this._promise = new Promise((resolve, reject) => {
|
|
41
|
+
this._resolve = resolve;
|
|
42
|
+
this._reject = reject;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Auto-start
|
|
46
|
+
this._start();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register observers for state changes, errors, and completion. */
|
|
50
|
+
on(
|
|
51
|
+
event: 'state_changed',
|
|
52
|
+
nextOrObserver?: Observer<UploadTaskSnapshot> | ((snap: UploadTaskSnapshot) => void),
|
|
53
|
+
error?: (err: Error) => void,
|
|
54
|
+
complete?: () => void
|
|
55
|
+
): () => void {
|
|
56
|
+
const observer: Observer<UploadTaskSnapshot> = typeof nextOrObserver === 'function'
|
|
57
|
+
? { next: nextOrObserver, error, complete }
|
|
58
|
+
: (nextOrObserver || { error, complete });
|
|
59
|
+
|
|
60
|
+
this._observers.push(observer);
|
|
61
|
+
|
|
62
|
+
// Immediately emit current state
|
|
63
|
+
if (observer.next) observer.next(this.snapshot);
|
|
64
|
+
|
|
65
|
+
// Return an unsubscribe function
|
|
66
|
+
return () => {
|
|
67
|
+
this._observers = this._observers.filter(o => o !== observer);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Suspend the upload. Only works elegantly on resumable uploads, but supported for all. */
|
|
72
|
+
pause(): boolean {
|
|
73
|
+
if (this._state !== 'running') return false;
|
|
74
|
+
this._state = 'paused';
|
|
75
|
+
if (this._activeXhr) {
|
|
76
|
+
this._activeXhr.abort(); // abort current chunk/request
|
|
77
|
+
this._activeXhr = undefined;
|
|
78
|
+
}
|
|
79
|
+
this._notifyObservers();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resume a paused upload. */
|
|
84
|
+
resume(): boolean {
|
|
85
|
+
if (this._state !== 'paused') return false;
|
|
86
|
+
this._state = 'running';
|
|
87
|
+
this._notifyObservers();
|
|
88
|
+
|
|
89
|
+
if (this._isResumable) {
|
|
90
|
+
this._continueResumable().catch(this._handleError);
|
|
91
|
+
} else {
|
|
92
|
+
// Simple upload must restart entirely if paused mid-flight
|
|
93
|
+
this._bytesTransferred = 0;
|
|
94
|
+
this._notifyObservers();
|
|
95
|
+
this._startSimple().catch(this._handleError);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Permanently cancel the upload, cleaning up partial server state if necessary. */
|
|
101
|
+
cancel(): boolean {
|
|
102
|
+
if (this._state === 'success' || this._state === 'error' || this._state === 'canceled') return false;
|
|
103
|
+
|
|
104
|
+
this._state = 'canceled';
|
|
105
|
+
if (this._activeXhr) {
|
|
106
|
+
this._activeXhr.abort();
|
|
107
|
+
this._activeXhr = undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cleanup Server State
|
|
111
|
+
if (this._isResumable && this._uploadId) {
|
|
112
|
+
this.client.post('/files/resumable/abort', { path: this.path, uploadId: this._uploadId }).catch(() => { });
|
|
113
|
+
} else if (!this._isResumable && this._simpleFileMetadata) {
|
|
114
|
+
this.client.delete(`/files/${encodeURIComponent(this.path)}`).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this._notifyObservers();
|
|
118
|
+
const err = new Error('Upload canceled by user');
|
|
119
|
+
err.name = 'UploadCanceled';
|
|
120
|
+
this._handleError(err);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get snapshot(): UploadTaskSnapshot {
|
|
125
|
+
return {
|
|
126
|
+
bytesTransferred: this._bytesTransferred,
|
|
127
|
+
totalBytes: this._totalBytes,
|
|
128
|
+
state: this._state,
|
|
129
|
+
task: this,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Promise implementation
|
|
134
|
+
then<TResult1 = UploadResult, TResult2 = never>(
|
|
135
|
+
onfulfilled?: ((value: UploadResult) => TResult1 | PromiseLike<TResult1>) | null,
|
|
136
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
137
|
+
): Promise<TResult1 | TResult2> {
|
|
138
|
+
return this._promise.then(onfulfilled, onrejected);
|
|
139
|
+
}
|
|
140
|
+
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null): Promise<UploadResult | TResult> {
|
|
141
|
+
return this._promise.catch(onrejected);
|
|
142
|
+
}
|
|
143
|
+
finally(onfinally?: (() => void) | null): Promise<UploadResult> {
|
|
144
|
+
return this._promise.finally(onfinally);
|
|
145
|
+
}
|
|
146
|
+
readonly [Symbol.toStringTag] = "UploadTask";
|
|
147
|
+
|
|
148
|
+
// ── Internal Upload Logic ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
private async _start() {
|
|
151
|
+
try {
|
|
152
|
+
// Processing Pipeline
|
|
153
|
+
if (this.options.compressImage && this._data.type.startsWith('image/')) {
|
|
154
|
+
this._data = await ImageHelper.compress(this._data, this.options.compressImage);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.options.encryptionKey) {
|
|
158
|
+
this._data = await CryptoHelper.encrypt(this._data, this.options.encryptionKey);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this._totalBytes = this._data.size;
|
|
162
|
+
|
|
163
|
+
// If the upload was canceled during the processing delay, halt.
|
|
164
|
+
if (this._state !== 'processing') return;
|
|
165
|
+
|
|
166
|
+
this._state = 'running';
|
|
167
|
+
this._notifyObservers();
|
|
168
|
+
|
|
169
|
+
const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // 50MB
|
|
170
|
+
this._isResumable = this._totalBytes >= MULTIPART_THRESHOLD;
|
|
171
|
+
|
|
172
|
+
if (this._isResumable) {
|
|
173
|
+
await this._startResumable();
|
|
174
|
+
} else {
|
|
175
|
+
await this._startSimple();
|
|
176
|
+
}
|
|
177
|
+
} catch (err: any) {
|
|
178
|
+
this._handleError(err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async _startSimple() {
|
|
183
|
+
// Init
|
|
184
|
+
const res = await this.client.post<{ uploadUrl: string; file: FileMetadata }>('/files/upload-url', {
|
|
185
|
+
path: this.path, name: this.name, size: this._totalBytes, contentType: this.contentType,
|
|
186
|
+
userId: this.options.userId, metadata: this.options.metadata,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
this._simpleFileMetadata = res.file;
|
|
190
|
+
if (this._state !== 'running') return; // aborted during init
|
|
191
|
+
|
|
192
|
+
// Upload
|
|
193
|
+
const xhr = new XMLHttpRequest();
|
|
194
|
+
this._activeXhr = xhr;
|
|
195
|
+
|
|
196
|
+
await new Promise<void>((resolve, reject) => {
|
|
197
|
+
xhr.open('PUT', res.uploadUrl);
|
|
198
|
+
xhr.setRequestHeader('Content-Type', this.contentType);
|
|
199
|
+
|
|
200
|
+
xhr.upload.onprogress = (e) => {
|
|
201
|
+
if (e.lengthComputable && this._state === 'running') {
|
|
202
|
+
this._bytesTransferred = e.loaded;
|
|
203
|
+
this._notifyObservers();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
xhr.onload = () => {
|
|
208
|
+
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
|
209
|
+
else reject(new Error(`Upload failed: ${xhr.status} ${xhr.responseText}`));
|
|
210
|
+
};
|
|
211
|
+
xhr.onerror = () => reject(new Error('Network error during upload'));
|
|
212
|
+
xhr.onabort = () => reject(new Error('Upload aborted'));
|
|
213
|
+
|
|
214
|
+
xhr.send(this._data);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
this._activeXhr = undefined;
|
|
218
|
+
if (this._state !== 'running') return;
|
|
219
|
+
|
|
220
|
+
// Complete
|
|
221
|
+
await this.client.post('/files/complete-upload', { path: this.path });
|
|
222
|
+
|
|
223
|
+
this._bytesTransferred = this._totalBytes;
|
|
224
|
+
this._state = 'success';
|
|
225
|
+
this._notifyObservers();
|
|
226
|
+
this._resolve({ file: res.file, resumable: false });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async _startResumable() {
|
|
230
|
+
// 1. Init Session
|
|
231
|
+
const init = await this.client.post<{ uploadId: string; file: FileMetadata }>('/files/resumable/init', {
|
|
232
|
+
path: this.path, name: this.name, size: this._totalBytes, contentType: this.contentType,
|
|
233
|
+
userId: this.options.userId, metadata: this.options.metadata,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
this._uploadId = init.uploadId;
|
|
237
|
+
this._simpleFileMetadata = init.file;
|
|
238
|
+
|
|
239
|
+
await this._continueResumable();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _continueResumable() {
|
|
243
|
+
if (!this._uploadId || !this._simpleFileMetadata) return;
|
|
244
|
+
|
|
245
|
+
const chunkSize = this.options.chunkSize ?? 10 * 1024 * 1024;
|
|
246
|
+
const totalParts = Math.ceil(this._totalBytes / chunkSize);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
while (this._currentPartIndex < totalParts) {
|
|
250
|
+
if (this._state !== 'running') return; // Paused or Canceled
|
|
251
|
+
|
|
252
|
+
const start = this._currentPartIndex * chunkSize;
|
|
253
|
+
const end = Math.min(start + chunkSize, this._totalBytes);
|
|
254
|
+
const chunk = this._data.slice(start, end);
|
|
255
|
+
|
|
256
|
+
// Get part URL
|
|
257
|
+
const partRes = await this.client.post<{ uploadUrl: string }>('/files/resumable/part-url', {
|
|
258
|
+
path: this.path, uploadId: this._uploadId, partNumber: this._currentPartIndex + 1
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (this._state !== 'running') return;
|
|
262
|
+
|
|
263
|
+
// Upload Chunk
|
|
264
|
+
const etag = await this._uploadChunkWithProgress(partRes.uploadUrl, chunk, start);
|
|
265
|
+
|
|
266
|
+
this._parts.push({ PartNumber: this._currentPartIndex + 1, ETag: etag });
|
|
267
|
+
this._currentPartIndex++;
|
|
268
|
+
this._bytesTransferred = Math.min(this._currentPartIndex * chunkSize, this._totalBytes);
|
|
269
|
+
this._notifyObservers();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (this._state === 'running') {
|
|
273
|
+
// Complete
|
|
274
|
+
await this.client.post('/files/resumable/complete', {
|
|
275
|
+
path: this.path, uploadId: this._uploadId, parts: this._parts
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
this._state = 'success';
|
|
279
|
+
this._notifyObservers();
|
|
280
|
+
this._resolve({ file: this._simpleFileMetadata, resumable: true });
|
|
281
|
+
}
|
|
282
|
+
} catch (err: any) {
|
|
283
|
+
if (err.message === 'Upload aborted') return; // Gracefully handled by pause/cancel
|
|
284
|
+
this._handleError(err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private _uploadChunkWithProgress(url: string, chunk: Blob, startOffset: number): Promise<string> {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const xhr = new XMLHttpRequest();
|
|
291
|
+
this._activeXhr = xhr;
|
|
292
|
+
|
|
293
|
+
xhr.open('PUT', url);
|
|
294
|
+
xhr.setRequestHeader('Content-Type', this.contentType);
|
|
295
|
+
|
|
296
|
+
xhr.upload.onprogress = (e) => {
|
|
297
|
+
if (e.lengthComputable && this._state === 'running') {
|
|
298
|
+
this._bytesTransferred = startOffset + e.loaded;
|
|
299
|
+
this._notifyObservers();
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
xhr.onload = () => {
|
|
304
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
305
|
+
const etag = xhr.getResponseHeader('ETag') || '';
|
|
306
|
+
resolve(etag.replace(/"/g, ''));
|
|
307
|
+
} else reject(new Error(`Part upload failed: ${xhr.status}`));
|
|
308
|
+
};
|
|
309
|
+
xhr.onerror = () => reject(new Error('Network error during part upload'));
|
|
310
|
+
xhr.onabort = () => reject(new Error('Upload aborted'));
|
|
311
|
+
|
|
312
|
+
xhr.send(chunk);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private _notifyObservers() {
|
|
317
|
+
const snap = this.snapshot;
|
|
318
|
+
this._observers.forEach(obs => {
|
|
319
|
+
if (snap.state === 'success' && obs.complete) obs.complete();
|
|
320
|
+
else if (obs.next) obs.next(snap);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private _handleError = (err: Error) => {
|
|
325
|
+
if (this._state === 'canceled') return; // Error naturally triggered via abort
|
|
326
|
+
this._state = 'error';
|
|
327
|
+
this._activeXhr = undefined;
|
|
328
|
+
this._notifyObservers();
|
|
329
|
+
this._observers.forEach(o => o.error?.(err));
|
|
330
|
+
this._reject(err);
|
|
331
|
+
}
|
|
332
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// TelestackStorage Web SDK — Main Entry Point
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
export { TelestackStorage } from './TelestackStorage.js';
|
|
5
|
+
export { StorageRef, FileRef, DirRef } from './StorageRef.js';
|
|
6
|
+
export { UploadTask } from './UploadTask.js';
|
|
7
|
+
export { BatchBuilder } from './BatchBuilder.js';
|
|
8
|
+
export { QueryBuilder } from './QueryBuilder.js';
|
|
9
|
+
export { HttpClient, TelestackError } from './HttpClient.js';
|
|
10
|
+
export { CryptoHelper } from './CryptoHelper.js';
|
|
11
|
+
export { ImageHelper } from './ImageHelper.js';
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
TelestackConfig,
|
|
15
|
+
FileMetadata,
|
|
16
|
+
UploadOptions,
|
|
17
|
+
DownloadOptions,
|
|
18
|
+
ListOptions,
|
|
19
|
+
SearchOptions,
|
|
20
|
+
SearchResult,
|
|
21
|
+
UploadResult,
|
|
22
|
+
UploadState,
|
|
23
|
+
UploadTaskSnapshot,
|
|
24
|
+
Observer,
|
|
25
|
+
TagSet,
|
|
26
|
+
BatchCopyResult,
|
|
27
|
+
BatchDeleteResult,
|
|
28
|
+
} from './types';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface TelestackConfig {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
defaultUploadOptions?: Partial<UploadOptions>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FileMetadata {
|
|
10
|
+
id: string;
|
|
11
|
+
tenant_id: string;
|
|
12
|
+
path: string;
|
|
13
|
+
name: string;
|
|
14
|
+
size: number;
|
|
15
|
+
content_type: string;
|
|
16
|
+
owner_id: string;
|
|
17
|
+
status: 'pending' | 'active' | 'deleted';
|
|
18
|
+
metadata: Record<string, any>;
|
|
19
|
+
created_at: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UploadOptions {
|
|
24
|
+
tenantId?: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
metadata?: Record<string, any>;
|
|
27
|
+
chunkSize?: number; // default 10MB
|
|
28
|
+
encryptionKey?: string; // Base64 AES-GCM key for E2EE
|
|
29
|
+
compressImage?: {
|
|
30
|
+
maxWidth?: number;
|
|
31
|
+
maxHeight?: number;
|
|
32
|
+
quality?: number; // 0.0 to 1.0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DownloadOptions {
|
|
37
|
+
versionId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ListOptions {
|
|
41
|
+
prefix?: string;
|
|
42
|
+
limit?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SearchOptions {
|
|
46
|
+
query?: string;
|
|
47
|
+
metadata?: Record<string, any>;
|
|
48
|
+
limit?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SearchResult {
|
|
52
|
+
success: boolean;
|
|
53
|
+
files: FileMetadata[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UploadResult {
|
|
57
|
+
file: FileMetadata;
|
|
58
|
+
resumable: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BatchDeleteResult {
|
|
62
|
+
success: boolean;
|
|
63
|
+
deletedCount: number;
|
|
64
|
+
errors: any[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BatchCopyResult {
|
|
68
|
+
success: boolean;
|
|
69
|
+
results: any[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TagSet {
|
|
73
|
+
Key: string;
|
|
74
|
+
Value: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Upload State Enum exactly like Firebase */
|
|
78
|
+
export type UploadState = 'processing' | 'running' | 'paused' | 'success' | 'error' | 'canceled';
|
|
79
|
+
|
|
80
|
+
export interface UploadTaskSnapshot {
|
|
81
|
+
bytesTransferred: number;
|
|
82
|
+
totalBytes: number;
|
|
83
|
+
state: UploadState;
|
|
84
|
+
task: import('./UploadTask').UploadTask;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type Observer<T> = {
|
|
88
|
+
next?: (value: T) => void;
|
|
89
|
+
error?: (error: Error) => void;
|
|
90
|
+
complete?: () => void;
|
|
91
|
+
};
|
package/test-e2e.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsdom_1 = require("jsdom");
|
|
4
|
+
const dom = new jsdom_1.JSDOM();
|
|
5
|
+
global.XMLHttpRequest = dom.window.XMLHttpRequest;
|
|
6
|
+
global.File = dom.window.File;
|
|
7
|
+
global.Blob = dom.window.Blob;
|
|
8
|
+
const jose_1 = require("jose");
|
|
9
|
+
const index_js_1 = require("./dist/index.js");
|
|
10
|
+
const TENANT_ID = 'test-tenant';
|
|
11
|
+
const USER_ID = 'user-sdk-test';
|
|
12
|
+
const JWT_SECRET = new TextEncoder().encode('telestack-super-secret-key-1234567890');
|
|
13
|
+
async function getAuthToken() {
|
|
14
|
+
return await new jose_1.SignJWT({
|
|
15
|
+
sub: USER_ID,
|
|
16
|
+
tenant_id: TENANT_ID,
|
|
17
|
+
role: 'admin',
|
|
18
|
+
email: 'test@example.com'
|
|
19
|
+
})
|
|
20
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
21
|
+
.setIssuedAt()
|
|
22
|
+
.setIssuer('telestack-auth')
|
|
23
|
+
.setAudience('telestack-storage')
|
|
24
|
+
.setExpirationTime('2h')
|
|
25
|
+
.sign(JWT_SECRET);
|
|
26
|
+
}
|
|
27
|
+
async function runSdkTests() {
|
|
28
|
+
const token = await getAuthToken();
|
|
29
|
+
const storage = new index_js_1.TelestackStorage({
|
|
30
|
+
baseUrl: 'http://localhost:8787',
|
|
31
|
+
tenantId: TENANT_ID,
|
|
32
|
+
token: token,
|
|
33
|
+
});
|
|
34
|
+
console.log('🧪 Starting Telestack Storage Web SDK Comprehensive Tests...\n');
|
|
35
|
+
try {
|
|
36
|
+
// --- 1. Simple Upload ---
|
|
37
|
+
console.log('1️⃣ Testing Simple Upload (UploadTask)...');
|
|
38
|
+
const simpleContent = new TextEncoder().encode('Hello Telestack SDK Simple Test!');
|
|
39
|
+
const simpleBlob = new Blob([simpleContent], { type: 'text/plain' });
|
|
40
|
+
const simpleTask = storage.ref('sdk-tests/simple.txt').put(simpleBlob, { userId: USER_ID });
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
simpleTask.on('state_changed', (snap) => console.log(` Progress: ${(snap.bytesTransferred / snap.totalBytes * 100).toFixed(0)}%`), (err) => reject(err), () => resolve());
|
|
43
|
+
});
|
|
44
|
+
console.log('✅ Simple Upload Completed\n');
|
|
45
|
+
// --- 2. Querying Metadata ---
|
|
46
|
+
console.log('2️⃣ Testing Query Builder...');
|
|
47
|
+
const queryResults = await storage.query()
|
|
48
|
+
.nameContains('simple.txt')
|
|
49
|
+
.limit(5)
|
|
50
|
+
.get();
|
|
51
|
+
console.log(` Found ${queryResults.length} files matching query.`);
|
|
52
|
+
if (queryResults.length === 0)
|
|
53
|
+
throw new Error('Query failed to find uploaded file.');
|
|
54
|
+
console.log('✅ Query Builder Passed\n');
|
|
55
|
+
// --- 3. Directory Listing ---
|
|
56
|
+
console.log('3️⃣ Testing Directory Refs (listAll)...');
|
|
57
|
+
const dirFiles = await storage.dir('sdk-tests/').listAll();
|
|
58
|
+
console.log(` Found ${dirFiles.length} files in directory.`);
|
|
59
|
+
console.log('✅ Directory Listing Passed\n');
|
|
60
|
+
// --- 4. Resumable Upload (Chunking) ---
|
|
61
|
+
console.log('4️⃣ Testing Resumable Upload (Chunks + Pause/Resume)...');
|
|
62
|
+
// Generate a 60MB string dynamically for testing multipart chunking
|
|
63
|
+
const bigContentSize = 60 * 1024 * 1024;
|
|
64
|
+
const bigArray = new Uint8Array(bigContentSize);
|
|
65
|
+
bigArray.fill(65); // Fill with 'A'
|
|
66
|
+
const bigBlob = new Blob([bigArray], { type: 'application/zip' });
|
|
67
|
+
const resumableTask = storage.ref('sdk-tests/big-resumable.bin').put(bigBlob, {
|
|
68
|
+
userId: USER_ID,
|
|
69
|
+
chunkSize: 20 * 1024 * 1024, // 20MB chunks
|
|
70
|
+
metadata: { test: 'sdk-resumable' }
|
|
71
|
+
});
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
let paused = false;
|
|
74
|
+
resumableTask.on('state_changed', (snap) => {
|
|
75
|
+
console.log(` Resumable Progress: ${(snap.bytesTransferred / snap.totalBytes * 100).toFixed(0)}% (${snap.bytesTransferred} bytes)`);
|
|
76
|
+
// Pause halfway
|
|
77
|
+
if (snap.bytesTransferred > 20 * 1024 * 1024 && !paused) {
|
|
78
|
+
paused = true;
|
|
79
|
+
console.log(' ⏸️ Pausing upload...');
|
|
80
|
+
resumableTask.pause();
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
console.log(' ▶️ Resuming upload...');
|
|
83
|
+
resumableTask.resume();
|
|
84
|
+
}, 2000);
|
|
85
|
+
}
|
|
86
|
+
}, (err) => reject(err), () => resolve());
|
|
87
|
+
});
|
|
88
|
+
console.log('✅ Resumable Multipart Upload Passed\n');
|
|
89
|
+
// --- 5. Download URL & Metadata CRUD ---
|
|
90
|
+
console.log('5️⃣ Testing Download URL & Metadata Update...');
|
|
91
|
+
const simpleRef = storage.ref('sdk-tests/simple.txt');
|
|
92
|
+
const url = await simpleRef.getDownloadUrl();
|
|
93
|
+
console.log(' Download URL:', url.substring(0, 50) + '...');
|
|
94
|
+
await simpleRef.updateMetadata({ newField: 'UpdatedSDKMetadata' });
|
|
95
|
+
const updatedMeta = await simpleRef.getMetadata();
|
|
96
|
+
if (updatedMeta.metadata.newField !== 'UpdatedSDKMetadata')
|
|
97
|
+
throw new Error('Metadata update failed');
|
|
98
|
+
console.log('✅ Metadata Update Passed\n');
|
|
99
|
+
// --- 5.5 End-to-End Encryption (E2EE) ---
|
|
100
|
+
console.log('🔒 Testing End-to-End Encryption (E2EE)...');
|
|
101
|
+
const { CryptoHelper } = require('./dist/index.js');
|
|
102
|
+
const e2eKey = await CryptoHelper.generateKey();
|
|
103
|
+
const secretContent = new TextEncoder().encode('Super Secret Confidential Data!');
|
|
104
|
+
const secretBlob = new Blob([secretContent], { type: 'text/plain' });
|
|
105
|
+
const secureTask = storage.ref('sdk-tests/secure-data.txt').put(secretBlob, {
|
|
106
|
+
userId: USER_ID,
|
|
107
|
+
encryptionKey: e2eKey
|
|
108
|
+
});
|
|
109
|
+
await new Promise((resolve, reject) => {
|
|
110
|
+
secureTask.on('state_changed', () => { }, (err) => reject(err), () => resolve());
|
|
111
|
+
});
|
|
112
|
+
// Verify we can fetch and decrypt it successfully
|
|
113
|
+
console.log(' Downloading and decrypting E2EE blob...');
|
|
114
|
+
const decryptedBlob = await storage.ref('sdk-tests/secure-data.txt').getDecryptedBlob(e2eKey);
|
|
115
|
+
const decryptedText = await decryptedBlob.text();
|
|
116
|
+
if (decryptedText !== 'Super Secret Confidential Data!') {
|
|
117
|
+
throw new Error('E2EE Decryption failed or ciphertext mismatch');
|
|
118
|
+
}
|
|
119
|
+
console.log('✅ End-to-End Encryption (E2EE) Passed\n');
|
|
120
|
+
// --- 6. Batch Operations ---
|
|
121
|
+
console.log('6️⃣ Testing Batch/Mass Operations over network...');
|
|
122
|
+
console.log(' Moving files to /archive...');
|
|
123
|
+
const moveRes = await storage.batch()
|
|
124
|
+
.move(['sdk-tests/simple.txt', 'sdk-tests/big-resumable.bin'], 'sdk-archive/tests/')
|
|
125
|
+
.run();
|
|
126
|
+
if (!moveRes.success)
|
|
127
|
+
throw new Error('Batch Move failed');
|
|
128
|
+
console.log('✅ Batch Move Passed\n');
|
|
129
|
+
// --- Final Cleanup ---
|
|
130
|
+
console.log('🧹 Formatting cleanup (Batch Delete)...');
|
|
131
|
+
const finalDel = await storage.batch()
|
|
132
|
+
.delete(['sdk-archive/tests/simple.txt', 'sdk-archive/tests/big-resumable.bin', 'sdk-tests/secure-data.txt'])
|
|
133
|
+
.run();
|
|
134
|
+
console.log(` Deleted ${finalDel.deletedCount} files.`);
|
|
135
|
+
console.log('\n🎉 ALL WEB SDK TESTS PASSED SUCCESSFULLY! The SDK is robust.');
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error('\n❌ WEB SDK TEST FAILED:', err.message || err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
runSdkTests();
|