@kevisual/oss 0.0.12 → 0.0.14

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/s3/core.ts ADDED
@@ -0,0 +1,425 @@
1
+ import {
2
+ S3Client,
3
+ GetObjectCommand,
4
+ PutObjectCommand,
5
+ DeleteObjectCommand,
6
+ ListObjectsV2Command,
7
+ HeadObjectCommand,
8
+ CopyObjectCommand,
9
+ type GetObjectCommandOutput,
10
+ } from '@aws-sdk/client-s3';
11
+ import type { Readable } from 'node:stream';
12
+ import fs from 'node:fs';
13
+ import { omit } from 'es-toolkit';
14
+ import {
15
+ OssBaseOperation,
16
+ ItemBucketMetadata,
17
+ UploadedObjectInfo,
18
+ StatObjectResult,
19
+ ListObjectResult,
20
+ ListFileObject,
21
+ } from './type.ts';
22
+ import { hash } from '../util/hash.ts';
23
+ import { getContentType } from '../util/get-content-type.ts';
24
+ import { extractStandardHeaders } from '../util/extract-standard-headers.ts';
25
+
26
+ export type OssBaseOptions<T = { [key: string]: any }> = {
27
+ /**
28
+ * 已经初始化好的 S3Client
29
+ */
30
+ client: S3Client;
31
+ /**
32
+ * 桶名
33
+ */
34
+ bucketName: string;
35
+ /**
36
+ * 前缀
37
+ */
38
+ prefix?: string;
39
+ } & T;
40
+
41
+ export class OssBase implements OssBaseOperation {
42
+ client: S3Client;
43
+ bucketName: string;
44
+ prefix = '';
45
+ /**
46
+ * 计算字符串或者对象的的md5值
47
+ */
48
+ hash = hash;
49
+
50
+ constructor(opts: OssBaseOptions) {
51
+ if (!opts.client) {
52
+ throw new Error('client is required');
53
+ }
54
+ this.bucketName = opts.bucketName;
55
+ this.client = opts.client;
56
+ this.prefix = opts?.prefix ?? '';
57
+ }
58
+
59
+ setPrefix(prefix: string) {
60
+ this.prefix = prefix;
61
+ }
62
+
63
+ /**
64
+ * 获取对象
65
+ * @param objectName 对象名
66
+ */
67
+ async getObject(objectName: string): Promise<GetObjectCommandOutput> {
68
+ const command = new GetObjectCommand({
69
+ Bucket: this.bucketName,
70
+ Key: `${this.prefix}${objectName}`,
71
+ });
72
+ const response = await this.client.send(command);
73
+ return response;
74
+ }
75
+
76
+ /**
77
+ * 获取对象内容为字符串
78
+ * @param objectName 对象名
79
+ */
80
+ async getObjectAsString(objectName: string): Promise<string> {
81
+ const response = await this.getObject(objectName);
82
+ if (response.Body) {
83
+ return await response.Body.transformToString();
84
+ }
85
+ throw new Error('Object body is empty');
86
+ }
87
+
88
+ /**
89
+ * 获取对象内容为 JSON
90
+ * @param objectName 对象名
91
+ */
92
+ async getJson(objectName: string): Promise<Record<string, any>> {
93
+ const str = await this.getObjectAsString(objectName);
94
+ try {
95
+ return JSON.parse(str);
96
+ } catch (error) {
97
+ throw new Error('Failed to parse JSON');
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 上传对象
103
+ * @param objectName 对象名
104
+ * @param data 数据
105
+ * @param metaData 元数据
106
+ * @param opts 选项
107
+ */
108
+ async putObject(
109
+ objectName: string,
110
+ data: Buffer | string | Object | Readable,
111
+ metaData: ItemBucketMetadata = {},
112
+ opts?: { check?: boolean; isStream?: boolean; size?: number; contentType?: string },
113
+ ): Promise<UploadedObjectInfo> {
114
+ let putData: Buffer | string | Readable;
115
+ let contentLength: number | undefined = opts?.size;
116
+ const isStream = opts?.isStream;
117
+
118
+ if (!isStream) {
119
+ if (typeof data === 'string') {
120
+ putData = data;
121
+ contentLength = Buffer.byteLength(data);
122
+ } else if (Buffer.isBuffer(data)) {
123
+ putData = data;
124
+ contentLength = data.length;
125
+ } else {
126
+ putData = JSON.stringify(data);
127
+ contentLength = Buffer.byteLength(putData);
128
+ }
129
+ } else {
130
+ putData = data as Readable;
131
+ if (!contentLength) {
132
+ throw new Error('Stream upload requires size parameter');
133
+ }
134
+ }
135
+
136
+ // 检查现有对象并合并元数据
137
+ if (opts?.check) {
138
+ const obj = await this.statObject(objectName, true);
139
+ if (obj) {
140
+ const omitMeta = ['size', 'content-type', 'cache-control', 'app-source'];
141
+ const objMeta = JSON.parse(JSON.stringify(omit(obj.metaData, omitMeta)));
142
+ metaData = {
143
+ ...objMeta,
144
+ ...metaData,
145
+ };
146
+ }
147
+ }
148
+
149
+ const { standardHeaders, customMetadata } = extractStandardHeaders(metaData);
150
+
151
+ const command = new PutObjectCommand({
152
+ Bucket: this.bucketName,
153
+ Key: `${this.prefix}${objectName}`,
154
+ Body: putData,
155
+ ContentLength: contentLength,
156
+ ContentType: opts?.contentType || standardHeaders.ContentType || getContentType(objectName),
157
+ CacheControl: standardHeaders.CacheControl,
158
+ ContentDisposition: standardHeaders.ContentDisposition,
159
+ ContentEncoding: standardHeaders.ContentEncoding,
160
+ ContentLanguage: standardHeaders.ContentLanguage,
161
+ Expires: standardHeaders.Expires,
162
+ Metadata: customMetadata,
163
+ });
164
+
165
+ const response = await this.client.send(command);
166
+ return {
167
+ etag: response.ETag?.replace(/"/g, '') || '',
168
+ versionId: response.VersionId || '',
169
+ };
170
+ }
171
+
172
+ /**
173
+ * 上传文件
174
+ * @param objectName 对象名
175
+ * @param filePath 文件路径
176
+ * @param metaData 元数据
177
+ */
178
+ async fPutObject(
179
+ objectName: string,
180
+ filePath: string,
181
+ metaData?: ItemBucketMetadata,
182
+ ): Promise<UploadedObjectInfo> {
183
+ const fileStream = fs.createReadStream(filePath);
184
+ const stat = fs.statSync(filePath);
185
+
186
+ const { standardHeaders, customMetadata } = extractStandardHeaders(metaData || {});
187
+
188
+ const command = new PutObjectCommand({
189
+ Bucket: this.bucketName,
190
+ Key: `${this.prefix}${objectName}`,
191
+ Body: fileStream,
192
+ ContentLength: stat.size,
193
+ ContentType: standardHeaders.ContentType || getContentType(filePath),
194
+ CacheControl: standardHeaders.CacheControl,
195
+ ContentDisposition: standardHeaders.ContentDisposition,
196
+ ContentEncoding: standardHeaders.ContentEncoding,
197
+ ContentLanguage: standardHeaders.ContentLanguage,
198
+ Expires: standardHeaders.Expires,
199
+ Metadata: customMetadata,
200
+ });
201
+
202
+ const response = await this.client.send(command);
203
+ return {
204
+ etag: response.ETag?.replace(/"/g, '') || '',
205
+ versionId: response.VersionId || '',
206
+ };
207
+ }
208
+
209
+ /**
210
+ * 删除对象
211
+ * @param objectName 对象名
212
+ */
213
+ async deleteObject(objectName: string): Promise<void> {
214
+ const command = new DeleteObjectCommand({
215
+ Bucket: this.bucketName,
216
+ Key: `${this.prefix}${objectName}`,
217
+ });
218
+ await this.client.send(command);
219
+ }
220
+
221
+ /**
222
+ * 列出对象
223
+ * @param objectName 前缀
224
+ * @param opts 选项
225
+ */
226
+ async listObjects<IS_FILE = false>(
227
+ objectName: string,
228
+ opts?: { recursive?: boolean; startAfter?: string; maxKeys?: number },
229
+ ): Promise<IS_FILE extends true ? ListFileObject[] : ListObjectResult[]> {
230
+ const prefix = `${this.prefix}${objectName}`;
231
+ const results: ListObjectResult[] = [];
232
+ let continuationToken: string | undefined;
233
+
234
+ do {
235
+ const command = new ListObjectsV2Command({
236
+ Bucket: this.bucketName,
237
+ Prefix: prefix,
238
+ Delimiter: opts?.recursive ? undefined : '/',
239
+ StartAfter: opts?.startAfter,
240
+ MaxKeys: opts?.maxKeys || 1000,
241
+ ContinuationToken: continuationToken,
242
+ });
243
+
244
+ const response = await this.client.send(command);
245
+
246
+ // 处理文件对象
247
+ if (response.Contents) {
248
+ for (const item of response.Contents) {
249
+ results.push({
250
+ name: item.Key || '',
251
+ size: item.Size || 0,
252
+ lastModified: item.LastModified || new Date(),
253
+ etag: item.ETag?.replace(/"/g, '') || '',
254
+ });
255
+ }
256
+ }
257
+
258
+ // 处理目录(CommonPrefixes)
259
+ if (response.CommonPrefixes && !opts?.recursive) {
260
+ for (const prefix of response.CommonPrefixes) {
261
+ results.push({
262
+ prefix: prefix.Prefix || '',
263
+ size: 0,
264
+ });
265
+ }
266
+ }
267
+
268
+ continuationToken = response.NextContinuationToken;
269
+ } while (continuationToken);
270
+
271
+ return results as IS_FILE extends true ? ListFileObject[] : ListObjectResult[];
272
+ }
273
+
274
+ /**
275
+ * 获取对象信息
276
+ * @param objectName 对象名
277
+ * @param checkFile 是否检查文件存在(不存在返回null而非抛错)
278
+ */
279
+ async statObject(objectName: string, checkFile = true): Promise<StatObjectResult | null> {
280
+ try {
281
+ const command = new HeadObjectCommand({
282
+ Bucket: this.bucketName,
283
+ Key: `${this.prefix}${objectName}`,
284
+ });
285
+ const response = await this.client.send(command);
286
+
287
+ return {
288
+ size: response.ContentLength || 0,
289
+ etag: response.ETag?.replace(/"/g, '') || '',
290
+ lastModified: response.LastModified || new Date(),
291
+ metaData: (response.Metadata as ItemBucketMetadata) || {},
292
+ versionId: response.VersionId || null,
293
+ };
294
+ } catch (e: any) {
295
+ if (checkFile && (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404)) {
296
+ return null;
297
+ }
298
+ throw e;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * 获取完整的对象名称
304
+ * @param objectName 对象名
305
+ */
306
+ getObjectName(objectName: string): string {
307
+ return `${this.prefix}${objectName}`;
308
+ }
309
+
310
+ /**
311
+ * 检查文件hash是否一致
312
+ * @param objectName 对象名
313
+ * @param hash hash值
314
+ * @param meta 元数据
315
+ */
316
+ async checkObjectHash(
317
+ objectName: string,
318
+ hash: string,
319
+ meta?: ItemBucketMetadata,
320
+ ): Promise<{ success: boolean; metaData: ItemBucketMetadata | null; obj: StatObjectResult | null; equalMeta?: boolean }> {
321
+ const obj = await this.statObject(objectName, true);
322
+ if (!obj) {
323
+ return { success: false, metaData: null, obj: null, equalMeta: false };
324
+ }
325
+ const omitMeta = ['content-type', 'cache-control', 'app-source'];
326
+ const metaData = omit(obj.metaData, omitMeta);
327
+ let equalMeta = false;
328
+ if (meta) {
329
+ equalMeta = JSON.stringify(metaData) === JSON.stringify(meta);
330
+ }
331
+ return { success: obj.etag === hash, metaData, obj, equalMeta };
332
+ }
333
+
334
+ /**
335
+ * 获取元数据
336
+ * @param pathname 路径名
337
+ * @param meta 元数据
338
+ */
339
+ getMetadata(pathname: string, meta: ItemBucketMetadata = { 'app-source': 'user-app' }): ItemBucketMetadata {
340
+ const isHtml = pathname.endsWith('.html');
341
+ if (isHtml) {
342
+ meta = {
343
+ ...meta,
344
+ 'content-type': 'text/html; charset=utf-8',
345
+ 'cache-control': 'no-cache',
346
+ };
347
+ } else {
348
+ meta = {
349
+ ...meta,
350
+ 'content-type': getContentType(pathname),
351
+ 'cache-control': 'max-age=31536000, immutable',
352
+ };
353
+ }
354
+ return meta;
355
+ }
356
+
357
+ /**
358
+ * 复制对象
359
+ * @param sourceObject 源对象
360
+ * @param targetObject 目标对象
361
+ */
362
+ async copyObject(sourceObject: string, targetObject: string): Promise<any> {
363
+ const command = new CopyObjectCommand({
364
+ Bucket: this.bucketName,
365
+ CopySource: `${this.bucketName}/${this.prefix}${sourceObject}`,
366
+ Key: `${this.prefix}${targetObject}`,
367
+ });
368
+ const response = await this.client.send(command);
369
+ return response;
370
+ }
371
+
372
+ /**
373
+ * 替换对象元数据
374
+ * @param objectName 对象名
375
+ * @param meta 新元数据
376
+ */
377
+ async replaceObject(objectName: string, meta: ItemBucketMetadata): Promise<any> {
378
+ const key = `${this.prefix}${objectName}`;
379
+ // 获取当前对象的元数据
380
+ const stat = await this.statObject(objectName, false);
381
+ const sourceMetadata = stat?.metaData || {};
382
+
383
+ const mergedMeta = { ...sourceMetadata, ...meta };
384
+ const { standardHeaders, customMetadata } = extractStandardHeaders(mergedMeta);
385
+
386
+ const command = new CopyObjectCommand({
387
+ Bucket: this.bucketName,
388
+ CopySource: `${this.bucketName}/${key}`,
389
+ Key: key,
390
+ ContentType: standardHeaders.ContentType,
391
+ CacheControl: standardHeaders.CacheControl,
392
+ ContentDisposition: standardHeaders.ContentDisposition,
393
+ ContentEncoding: standardHeaders.ContentEncoding,
394
+ ContentLanguage: standardHeaders.ContentLanguage,
395
+ Expires: standardHeaders.Expires,
396
+ Metadata: customMetadata,
397
+ MetadataDirective: 'REPLACE',
398
+ });
399
+ const response = await this.client.send(command);
400
+ return response;
401
+ }
402
+
403
+ /**
404
+ * 创建实例
405
+ */
406
+ static create<T extends OssBase, U>(this: new (opts: OssBaseOptions<U>) => T, opts: OssBaseOptions<U>): T {
407
+ return new this(opts);
408
+ }
409
+
410
+ /**
411
+ * 从已有实例创建
412
+ */
413
+ static fromBase<T extends OssBase, U>(
414
+ this: new (opts: OssBaseOptions<U>) => T,
415
+ createOpts: { oss: OssBase; opts: Partial<OssBaseOptions<U>> },
416
+ ): T {
417
+ const base = createOpts.oss;
418
+ const opts = createOpts.opts as any;
419
+ return new this({
420
+ client: base.client,
421
+ bucketName: base.bucketName,
422
+ ...opts,
423
+ });
424
+ }
425
+ }
package/src/s3/type.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { CopyObjectCommandOutput } from '@aws-sdk/client-s3';
2
+ import { Readable } from 'node:stream';
3
+
4
+ export type ItemBucketMetadata = Record<string, string>;
5
+
6
+ export type UploadedObjectInfo = {
7
+ etag: string;
8
+ lastModified?: Date;
9
+ size?: number;
10
+ versionId: string;
11
+ metadata?: ItemBucketMetadata;
12
+ };
13
+
14
+ export type StatObjectResult = {
15
+ size: number;
16
+ etag: string;
17
+ lastModified: Date;
18
+ metaData: ItemBucketMetadata;
19
+ versionId?: string | null;
20
+ };
21
+
22
+ export type ListFileObject = {
23
+ name: string;
24
+ size: number;
25
+ lastModified: Date;
26
+ etag: string;
27
+ };
28
+
29
+ export type ListDirectoryObject = {
30
+ prefix: string;
31
+ size: number;
32
+ };
33
+
34
+ export type ListObjectResult = ListFileObject | ListDirectoryObject;
35
+
36
+ export interface OssBaseOperation {
37
+ prefix: string;
38
+
39
+ /**
40
+ * 设置前缀
41
+ * @param prefix 前缀
42
+ */
43
+ setPrefix(prefix: string): void;
44
+
45
+ /**
46
+ * 获取对象
47
+ * @param objectName 对象名
48
+ */
49
+ getObject(objectName: string): Promise<any>;
50
+
51
+ /**
52
+ * 获取对象内容为字符串
53
+ * @param objectName 对象名
54
+ */
55
+ getObjectAsString?(objectName: string): Promise<string>;
56
+
57
+ /**
58
+ * 获取对象内容为 JSON
59
+ * @param objectName 对象名
60
+ */
61
+ getJson?(objectName: string): Promise<Record<string, any>>;
62
+
63
+ /**
64
+ * 上传对象
65
+ * @param objectName 对象名
66
+ * @param data 数据
67
+ * @param metaData 元数据
68
+ * @param opts 选项
69
+ */
70
+ putObject(
71
+ objectName: string,
72
+ data: Buffer | string | Object | Readable,
73
+ metaData?: ItemBucketMetadata,
74
+ opts?: { check?: boolean; isStream?: boolean; size?: number; contentType?: string },
75
+ ): Promise<UploadedObjectInfo>;
76
+
77
+ /**
78
+ * 上传文件
79
+ * @param objectName 对象名
80
+ * @param filePath 文件路径
81
+ * @param metaData 元数据
82
+ */
83
+ fPutObject(objectName: string, filePath: string, metaData?: ItemBucketMetadata): Promise<UploadedObjectInfo>;
84
+
85
+ /**
86
+ * 获取对象信息
87
+ * @param objectName 对象名
88
+ * @param checkFile 是否检查文件存在(不存在返回null而非抛错)
89
+ */
90
+ statObject(objectName: string, checkFile?: boolean): Promise<StatObjectResult | null>;
91
+
92
+ /**
93
+ * 删除对象
94
+ * @param objectName 对象名
95
+ */
96
+ deleteObject(objectName: string): Promise<any>;
97
+
98
+ /**
99
+ * 列出对象
100
+ * @param objectName 前缀
101
+ * @param opts 选项
102
+ */
103
+ listObjects(
104
+ objectName: string,
105
+ opts?: {
106
+ /** 是否递归 */
107
+ recursive?: boolean;
108
+ /** 开始位置 */
109
+ startAfter?: string;
110
+ /** 最大返回数量 */
111
+ maxKeys?: number;
112
+ },
113
+ ): Promise<ListObjectResult[]>;
114
+
115
+ /**
116
+ * 获取完整的对象名称
117
+ * @param objectName 对象名
118
+ */
119
+ getObjectName?(objectName: string): string;
120
+
121
+ /**
122
+ * 检查文件hash是否一致
123
+ * @param objectName 对象名
124
+ * @param hash hash值
125
+ * @param meta 元数据
126
+ */
127
+ checkObjectHash?(
128
+ objectName: string,
129
+ hash: string,
130
+ meta?: ItemBucketMetadata,
131
+ ): Promise<{ success: boolean; metaData: ItemBucketMetadata | null; obj: StatObjectResult | null; equalMeta?: boolean }>;
132
+
133
+ /**
134
+ * 获取元数据
135
+ * @param pathname 路径名
136
+ * @param meta 元数据
137
+ */
138
+ getMetadata?(pathname: string, meta?: ItemBucketMetadata): ItemBucketMetadata;
139
+
140
+ /**
141
+ * 复制对象
142
+ * @param sourceObject 源对象
143
+ * @param targetObject 目标对象
144
+ */
145
+ copyObject(sourceObject: string, targetObject: string): Promise<CopyObjectCommandOutput>;
146
+
147
+ /**
148
+ * 替换对象元数据
149
+ * @param objectName 对象名
150
+ * @param meta 新元数据
151
+ */
152
+ replaceObject?(objectName: string, meta: ItemBucketMetadata): Promise<any>;
153
+ }
154
+
155
+ export interface OssService extends OssBaseOperation {
156
+ owner: string;
157
+ }
@@ -0,0 +1,67 @@
1
+ import { OssBase, OssBaseOptions } from '../index.ts';
2
+ import { OssService } from '../core/type.ts';
3
+ import * as util from '../util/index.ts';
4
+
5
+ export class ConfigOssService extends OssBase implements OssService {
6
+ owner: string;
7
+ constructor(opts: OssBaseOptions<{ owner: string }>) {
8
+ super(opts);
9
+ this.owner = opts.owner;
10
+ this.setPrefix(`${this.owner}/config/1.0.0/`);
11
+ }
12
+ async listAllFile() {
13
+ const list = await this.listObjects<true>('');
14
+ return list.filter((item) => item.size > 0 && this.isEndWithJson(item.name));
15
+ }
16
+ async listAll() {
17
+ return await this.listObjects<false>('');
18
+ }
19
+ configMap = new Map<string, any>();
20
+ keys: string[] = [];
21
+ async getAllConfigJson() {
22
+ const list = await this.listAllFile();
23
+ return list;
24
+ }
25
+ isEndWithJson(string: string) {
26
+ return string?.endsWith?.(`.json`);
27
+ }
28
+ putJsonObject(key: string, data: any) {
29
+ const json = util.hashSringify(data);
30
+ return this.putObject(key, json, {
31
+ 'Content-Type': 'application/json',
32
+ 'app-source': 'user-config-app',
33
+ 'Cache-Control': 'max-age=31536000, immutable',
34
+ });
35
+ }
36
+ async getObjectList(objectNameList: string[]) {
37
+ const jsonMap = new Map<string, Record<string, any>>();
38
+ for (const objectName of objectNameList) {
39
+ try {
40
+ const json = await this.getJson(objectName);
41
+ jsonMap.set(objectName, json);
42
+ } catch (error) {
43
+ console.error(error);
44
+ continue;
45
+ }
46
+ }
47
+ return jsonMap;
48
+ }
49
+ async getList() {
50
+ const list = await this.listAllFile();
51
+ /**
52
+ * key -> etag
53
+ */
54
+ const keyEtagMap = new Map<string, Etag>();
55
+ const listKeys = list.map((item) => {
56
+ const key = item.name.replace(this.prefix, '');
57
+ keyEtagMap.set(key, item.etag);
58
+ return {
59
+ ...item,
60
+ key,
61
+ };
62
+ });
63
+ type Etag = string;
64
+ const keys = Array.from(keyEtagMap.keys());
65
+ return { list: listKeys, keys, keyEtagMap };
66
+ }
67
+ }
@@ -0,0 +1,9 @@
1
+ export { OssBase } from '../index.ts';
2
+ export type { OssBaseOptions } from '../index.ts';
3
+ export { ConfigOssService } from './config.ts';
4
+
5
+ export * from '../util/download.ts';
6
+
7
+ export * from '../util/index.ts';
8
+
9
+ export * from '../core/type.ts';