@svadmin/storage 0.0.0-stage → 0.2.1
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/package.json +47 -4
- package/src/index.ts +17 -0
- package/src/memory-storage.ts +89 -0
- package/src/s3-storage.ts +112 -0
- package/src/upload-utils.ts +81 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/storage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Storage adapters — S3, Cloudflare R2, and in-memory storage provider for svadmin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"src/**/*.ts",
|
|
9
|
+
"!src/**/*.test.*",
|
|
10
|
+
"!src/**/*.spec.*"
|
|
11
|
+
],
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@svadmin/core": "^0.76.0"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"svadmin",
|
|
25
|
+
"storage",
|
|
26
|
+
"s3",
|
|
27
|
+
"r2",
|
|
28
|
+
"storage-provider"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "zuohuadong",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/vibeunion/svadmin.git",
|
|
35
|
+
"directory": "packages/storage"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/vibeunion/svadmin#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/vibeunion/svadmin/issues"
|
|
40
|
+
},
|
|
41
|
+
"svadmin": {
|
|
42
|
+
"capabilities": [
|
|
43
|
+
"storage"
|
|
44
|
+
],
|
|
45
|
+
"core": "^0.55.0",
|
|
46
|
+
"stability": "stable",
|
|
47
|
+
"migration": null
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { createMemoryStorageProvider } from './memory-storage';
|
|
2
|
+
export type { MemoryStorageOptions } from './memory-storage';
|
|
3
|
+
|
|
4
|
+
export { createS3StorageProvider } from './s3-storage';
|
|
5
|
+
export type { FetchLike, S3StorageOptions } from './s3-storage';
|
|
6
|
+
|
|
7
|
+
export { validateFile, uploadBatch } from './upload-utils';
|
|
8
|
+
export type { FileValidationOptions, BatchUploadOptions } from './upload-utils';
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
StorageProvider,
|
|
12
|
+
StorageUploadOptions,
|
|
13
|
+
StorageUploadResult,
|
|
14
|
+
StoragePresignedUrlOptions,
|
|
15
|
+
StorageFileItem,
|
|
16
|
+
StorageListOptions,
|
|
17
|
+
} from '@svadmin/core';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StorageProvider,
|
|
3
|
+
StorageUploadOptions,
|
|
4
|
+
StorageUploadResult,
|
|
5
|
+
StorageFileItem,
|
|
6
|
+
StorageListOptions,
|
|
7
|
+
StoragePresignedUrlOptions,
|
|
8
|
+
} from '@svadmin/core';
|
|
9
|
+
|
|
10
|
+
export interface MemoryStorageOptions {
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createMemoryStorageProvider(options: MemoryStorageOptions = {}): StorageProvider {
|
|
15
|
+
const baseUrl = (options.baseUrl ?? 'https://storage.local').replace(/\/+$/, '');
|
|
16
|
+
const files = new Map<string, { bytes: Uint8Array; contentType: string; metadata?: Record<string, string>; updatedAt: Date }>();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
async upload(file: File | Blob | ArrayBuffer | Uint8Array, uploadOptions?: StorageUploadOptions): Promise<StorageUploadResult> {
|
|
20
|
+
const filename = uploadOptions?.filename ?? (file instanceof File ? file.name : 'upload.bin');
|
|
21
|
+
const dir = uploadOptions?.path ? uploadOptions.path.replace(/^\/+|\/+$/g, '') : '';
|
|
22
|
+
const fullPath = dir ? `${dir}/${filename}` : filename;
|
|
23
|
+
|
|
24
|
+
let bytes: Uint8Array;
|
|
25
|
+
if (file instanceof Uint8Array) {
|
|
26
|
+
bytes = file;
|
|
27
|
+
} else if (file instanceof ArrayBuffer) {
|
|
28
|
+
bytes = new Uint8Array(file);
|
|
29
|
+
} else {
|
|
30
|
+
bytes = new Uint8Array(await file.arrayBuffer());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const contentType = uploadOptions?.contentType ?? (typeof File !== 'undefined' && file instanceof File ? file.type : 'application/octet-stream');
|
|
34
|
+
files.set(fullPath, {
|
|
35
|
+
bytes,
|
|
36
|
+
contentType: contentType || 'application/octet-stream',
|
|
37
|
+
updatedAt: new Date(),
|
|
38
|
+
...(uploadOptions?.metadata === undefined ? {} : { metadata: uploadOptions.metadata }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
uploadOptions?.onProgress?.({
|
|
42
|
+
loaded: bytes.byteLength,
|
|
43
|
+
total: bytes.byteLength,
|
|
44
|
+
percentage: 100,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
url: `${baseUrl}/${fullPath}`,
|
|
49
|
+
path: fullPath,
|
|
50
|
+
size: bytes.byteLength,
|
|
51
|
+
contentType,
|
|
52
|
+
...(uploadOptions?.metadata === undefined ? {} : { metadata: uploadOptions.metadata }),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async delete(path: string): Promise<void> {
|
|
57
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
58
|
+
files.delete(cleanPath);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async getPresignedUrl(path: string, presignedOptions?: StoragePresignedUrlOptions): Promise<string> {
|
|
62
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
63
|
+
const op = presignedOptions?.operation ?? 'get';
|
|
64
|
+
const expires = presignedOptions?.expiresInSeconds ?? 3600;
|
|
65
|
+
return `${baseUrl}/${cleanPath}?op=${op}&expires=${expires}&signature=mock-sig`;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async list(listOptions?: StorageListOptions): Promise<StorageFileItem[]> {
|
|
69
|
+
const prefix = listOptions?.prefix ? listOptions.prefix.replace(/^\/+/, '') : '';
|
|
70
|
+
const limit = listOptions?.limit ?? 1000;
|
|
71
|
+
const result: StorageFileItem[] = [];
|
|
72
|
+
|
|
73
|
+
for (const [key, value] of files.entries()) {
|
|
74
|
+
if (!prefix || key.startsWith(prefix)) {
|
|
75
|
+
result.push({
|
|
76
|
+
name: key.split('/').pop() ?? key,
|
|
77
|
+
path: key,
|
|
78
|
+
size: value.bytes.byteLength,
|
|
79
|
+
lastModified: value.updatedAt,
|
|
80
|
+
url: `${baseUrl}/${key}`,
|
|
81
|
+
});
|
|
82
|
+
if (result.length >= limit) break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StorageProvider,
|
|
3
|
+
StorageUploadOptions,
|
|
4
|
+
StorageUploadResult,
|
|
5
|
+
StorageFileItem,
|
|
6
|
+
StorageListOptions,
|
|
7
|
+
StoragePresignedUrlOptions,
|
|
8
|
+
} from '@svadmin/core';
|
|
9
|
+
|
|
10
|
+
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
|
|
12
|
+
export interface S3StorageOptions {
|
|
13
|
+
/** S3 or S3-compatible (Cloudflare R2, MinIO, Wasabi) bucket name */
|
|
14
|
+
bucket: string;
|
|
15
|
+
/** Public or CDN base URL for downloading files */
|
|
16
|
+
publicUrl?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Function to request a presigned PUT URL from your backend or serverless handler.
|
|
19
|
+
* Enables direct client-to-storage uploads without exposing credentials.
|
|
20
|
+
*/
|
|
21
|
+
getPresignedUploadUrl?: (params: { path: string; contentType?: string }) => Promise<{ uploadUrl: string; downloadUrl?: string }>;
|
|
22
|
+
/** Request a signed download or upload URL from the backend. */
|
|
23
|
+
getPresignedUrl?: (params: { path: string; operation: 'get' | 'put'; expiresInSeconds: number }) => Promise<string>;
|
|
24
|
+
/** List objects through a backend endpoint; direct bucket listing is not exposed to browsers. */
|
|
25
|
+
listFiles?: (options?: StorageListOptions) => Promise<StorageFileItem[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Custom fetch implementation
|
|
28
|
+
*/
|
|
29
|
+
fetch?: FetchLike;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createS3StorageProvider(options: S3StorageOptions): StorageProvider {
|
|
33
|
+
const customFetch = options.fetch ?? fetch;
|
|
34
|
+
const publicBaseUrl = (options.publicUrl ?? `https://${options.bucket}.s3.amazonaws.com`).replace(/\/+$/, '');
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
async upload(file: File | Blob | ArrayBuffer | Uint8Array, uploadOptions?: StorageUploadOptions): Promise<StorageUploadResult> {
|
|
38
|
+
const filename = uploadOptions?.filename ?? (typeof File !== 'undefined' && file instanceof File ? file.name : 'upload.bin');
|
|
39
|
+
const dir = uploadOptions?.path ? uploadOptions.path.replace(/^\/+|\/+$/g, '') : '';
|
|
40
|
+
const fullPath = dir ? `${dir}/${filename}` : filename;
|
|
41
|
+
const contentType = uploadOptions?.contentType ?? (typeof File !== 'undefined' && file instanceof File ? file.type : 'application/octet-stream');
|
|
42
|
+
|
|
43
|
+
let bodyData: BodyInit;
|
|
44
|
+
let byteSize: number | undefined;
|
|
45
|
+
|
|
46
|
+
if (file instanceof Uint8Array || file instanceof ArrayBuffer) {
|
|
47
|
+
bodyData = file as BodyInit;
|
|
48
|
+
byteSize = file.byteLength;
|
|
49
|
+
} else {
|
|
50
|
+
bodyData = file;
|
|
51
|
+
byteSize = file.size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (options.getPresignedUploadUrl) {
|
|
55
|
+
const presigned = await options.getPresignedUploadUrl({
|
|
56
|
+
path: fullPath,
|
|
57
|
+
contentType,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const uploadRes = await customFetch(presigned.uploadUrl, {
|
|
61
|
+
method: 'PUT',
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': contentType || 'application/octet-stream',
|
|
64
|
+
},
|
|
65
|
+
body: bodyData,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!uploadRes.ok) {
|
|
69
|
+
throw new Error(`Failed to upload to presigned S3 URL: ${uploadRes.status} ${uploadRes.statusText}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
uploadOptions?.onProgress?.({
|
|
73
|
+
loaded: byteSize ?? 100,
|
|
74
|
+
total: byteSize ?? 100,
|
|
75
|
+
percentage: 100,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
url: presigned.downloadUrl ?? `${publicBaseUrl}/${fullPath}`,
|
|
80
|
+
path: fullPath,
|
|
81
|
+
size: byteSize,
|
|
82
|
+
contentType,
|
|
83
|
+
...(uploadOptions?.metadata === undefined ? {} : { metadata: uploadOptions.metadata }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error('S3 storage upload requires getPresignedUploadUrl');
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async delete(path: string): Promise<void> {
|
|
91
|
+
// In client mode, delete operations typically call the backend API
|
|
92
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
93
|
+
const response = await customFetch(`${publicBaseUrl}/${cleanPath}`, {
|
|
94
|
+
method: 'DELETE',
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) throw new Error(`Failed to delete storage object: ${response.status} ${response.statusText}`);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async getPresignedUrl(path: string, presignedOptions?: StoragePresignedUrlOptions): Promise<string> {
|
|
100
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
101
|
+
const op = presignedOptions?.operation ?? 'get';
|
|
102
|
+
const expires = presignedOptions?.expiresInSeconds ?? 3600;
|
|
103
|
+
if (!options.getPresignedUrl) throw new Error('S3 storage presigned URLs require getPresignedUrl');
|
|
104
|
+
return options.getPresignedUrl({ path: cleanPath, operation: op, expiresInSeconds: expires });
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async list(listOptions?: StorageListOptions): Promise<StorageFileItem[]> {
|
|
108
|
+
if (!options.listFiles) throw new Error('S3 storage listing requires listFiles');
|
|
109
|
+
return options.listFiles(listOptions);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { StorageProvider, StorageUploadOptions, StorageUploadResult } from '@svadmin/core';
|
|
2
|
+
|
|
3
|
+
export interface FileValidationOptions {
|
|
4
|
+
maxSizeBytes?: number;
|
|
5
|
+
allowedMimeTypes?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateFile(file: File | Blob, options: FileValidationOptions): { valid: boolean; error?: string } {
|
|
9
|
+
if (options.maxSizeBytes && file.size > options.maxSizeBytes) {
|
|
10
|
+
const maxMb = (options.maxSizeBytes / (1024 * 1024)).toFixed(1);
|
|
11
|
+
return {
|
|
12
|
+
valid: false,
|
|
13
|
+
error: `File size exceeds maximum limit of ${maxMb}MB`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (options.allowedMimeTypes && options.allowedMimeTypes.length > 0 && file.type) {
|
|
18
|
+
const isAllowed = options.allowedMimeTypes.some((pattern) => {
|
|
19
|
+
if (pattern.endsWith('/*')) {
|
|
20
|
+
const prefix = pattern.slice(0, -1);
|
|
21
|
+
return file.type.startsWith(prefix);
|
|
22
|
+
}
|
|
23
|
+
return file.type === pattern;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!isAllowed) {
|
|
27
|
+
return {
|
|
28
|
+
valid: false,
|
|
29
|
+
error: `File type "${file.type}" is not allowed. Supported: ${options.allowedMimeTypes.join(', ')}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { valid: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BatchUploadOptions extends StorageUploadOptions {
|
|
38
|
+
concurrency?: number;
|
|
39
|
+
onOverallProgress?: (progress: { completed: number; total: number; percentage: number }) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function uploadBatch(
|
|
43
|
+
storage: StorageProvider,
|
|
44
|
+
files: (File | Blob)[],
|
|
45
|
+
options: BatchUploadOptions = {},
|
|
46
|
+
): Promise<StorageUploadResult[]> {
|
|
47
|
+
const total = files.length;
|
|
48
|
+
if (total === 0) return [];
|
|
49
|
+
|
|
50
|
+
const { concurrency: requestedConcurrency, onOverallProgress, ...uploadOptions } = options;
|
|
51
|
+
const concurrency = Math.max(1, requestedConcurrency ?? 3);
|
|
52
|
+
const results: StorageUploadResult[] = new Array(total);
|
|
53
|
+
let completed = 0;
|
|
54
|
+
|
|
55
|
+
async function uploadWorker(startIndex: number, step: number) {
|
|
56
|
+
for (let i = startIndex; i < total; i += step) {
|
|
57
|
+
const file = files[i];
|
|
58
|
+
if (!file) throw new Error(`Missing file at batch index ${i}`);
|
|
59
|
+
const filename = typeof File !== 'undefined' && file instanceof File ? file.name : `file-${i}.bin`;
|
|
60
|
+
const res = await storage.upload(file, {
|
|
61
|
+
...uploadOptions,
|
|
62
|
+
filename: uploadOptions.filename ? `${i}-${uploadOptions.filename}` : filename,
|
|
63
|
+
});
|
|
64
|
+
results[i] = res;
|
|
65
|
+
completed++;
|
|
66
|
+
onOverallProgress?.({
|
|
67
|
+
completed,
|
|
68
|
+
total,
|
|
69
|
+
percentage: Math.round((completed / total) * 100),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const workers: Promise<void>[] = [];
|
|
75
|
+
for (let c = 0; c < Math.min(concurrency, total); c++) {
|
|
76
|
+
workers.push(uploadWorker(c, concurrency));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await Promise.all(workers);
|
|
80
|
+
return results;
|
|
81
|
+
}
|
package/README.md
DELETED