@kevisual/oss 0.0.13 → 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/dist/index.d.ts +1 -1
- package/dist/index.js +11 -2049
- package/dist/services/config.d.ts +1 -1
- package/dist/services/config.js +36 -2049
- package/dist/services/index.d.ts +20 -2
- package/dist/services/index.js +38 -2049
- package/package.json +11 -12
- package/src/core/copy-object.ts +26 -0
- package/src/core/type.ts +87 -0
- package/src/index.ts +244 -0
- package/src/s3/copy-object.ts +46 -0
- package/src/s3/core.ts +425 -0
- package/src/s3/type.ts +157 -0
- package/src/services/config.ts +67 -0
- package/src/services/index.ts +9 -0
- package/src/test/common.ts +103 -0
- package/src/test/config-admin.ts +84 -0
- package/src/test/test-s3.ts +23 -0
- package/src/util/download.ts +73 -0
- package/src/util/extract-standard-headers.ts +44 -0
- package/src/util/get-content-type.ts +49 -0
- package/src/util/hash.ts +26 -0
- package/src/util/index.ts +5 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as dotenv from 'dotenv';
|
|
2
|
+
dotenv.config();
|
|
3
|
+
|
|
4
|
+
import { S3Client, ListObjectsV2Command, GetBucketMetadataConfigurationCommand, HeadObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
5
|
+
|
|
6
|
+
export const s3Client = new S3Client({
|
|
7
|
+
credentials: {
|
|
8
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
|
|
9
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
|
|
10
|
+
},
|
|
11
|
+
region: process.env.S3_REGION,
|
|
12
|
+
endpoint: 'https://tos-s3-cn-shanghai.volces.com',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const bucketName = process.env.S3_BUCKET_NAME;
|
|
16
|
+
|
|
17
|
+
export async function listS3Objects() {
|
|
18
|
+
const command = new ListObjectsV2Command({
|
|
19
|
+
Bucket: process.env.S3_BUCKET_NAME,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await s3Client.send(command);
|
|
24
|
+
console.log('S3 Objects:', result.Contents);
|
|
25
|
+
return result.Contents;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error listing S3 objects:', error);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// listS3Objects();
|
|
33
|
+
|
|
34
|
+
export async function getMetaData(key = 'readme.md') {
|
|
35
|
+
const command = new HeadObjectCommand({
|
|
36
|
+
Bucket: process.env.S3_BUCKET_NAME,
|
|
37
|
+
Key: key,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const result = await s3Client.send(command);
|
|
42
|
+
// 解码 TOS 返回的 URL 编码的元数据值
|
|
43
|
+
const decodedMetadata: Record<string, string> = {};
|
|
44
|
+
if (result.Metadata) {
|
|
45
|
+
for (const [key, value] of Object.entries(result.Metadata)) {
|
|
46
|
+
decodedMetadata[key] = decodeURIComponent(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
console.log('Metadata for', result);
|
|
50
|
+
|
|
51
|
+
return decodedMetadata;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Error getting metadata for', key, ':', error);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// const metadata = await getMetaData();
|
|
59
|
+
// console.log('metadata', metadata);
|
|
60
|
+
|
|
61
|
+
export async function setMetaData(key = 'readme.md', metadata: Record<string, string>) {
|
|
62
|
+
// 注意:S3 不支持直接更新对象的元数据。必须通过复制对象到自身来实现元数据的更新。
|
|
63
|
+
const copySource = `${process.env.S3_BUCKET_NAME}/${key}`;
|
|
64
|
+
|
|
65
|
+
// 分离标准 HTTP 头和自定义元数据
|
|
66
|
+
// 标准头应作为顶层参数,自定义元数据才放在 Metadata 中
|
|
67
|
+
const standardHeaders: Record<string, any> = {};
|
|
68
|
+
const customMetadata: Record<string, string> = {};
|
|
69
|
+
|
|
70
|
+
const standardHeaderKeys = ['content-type', 'cache-control', 'content-disposition', 'content-encoding', 'content-language', 'expires'];
|
|
71
|
+
|
|
72
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
73
|
+
const lowerKey = key.toLowerCase();
|
|
74
|
+
if (standardHeaderKeys.includes(lowerKey)) {
|
|
75
|
+
// 使用驼峰命名
|
|
76
|
+
const camelKey = lowerKey.split('-').map((word, index) =>
|
|
77
|
+
index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1)
|
|
78
|
+
).join('');
|
|
79
|
+
standardHeaders[camelKey] = value;
|
|
80
|
+
} else {
|
|
81
|
+
customMetadata[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const command = new CopyObjectCommand({
|
|
86
|
+
Bucket: process.env.S3_BUCKET_NAME,
|
|
87
|
+
Key: key,
|
|
88
|
+
CopySource: copySource,
|
|
89
|
+
...standardHeaders, // 标准头作为顶层参数
|
|
90
|
+
Metadata: customMetadata, // 只有自定义元数据放在这里
|
|
91
|
+
MetadataDirective: 'REPLACE', // 指定替换元数据
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const result = await s3Client.send(command);
|
|
96
|
+
console.log('Metadata updated successfully for', key);
|
|
97
|
+
return result;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error('Error setting metadata for', key, ':', error);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// setMetaData('readme.md', { 'type': 'app', 'Content-Type': 'text/html' });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
import { ConfigOssService } from '../services/index.ts';
|
|
3
|
+
import { Client } from 'minio';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { downloadObject } from '../util/download.ts';
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
config({ path: path.resolve(cwd, '..', '..', '.env.dev') });
|
|
8
|
+
|
|
9
|
+
console.log(
|
|
10
|
+
'config',
|
|
11
|
+
process.env.MINIO_ENDPOINT,
|
|
12
|
+
process.env.MINIO_USE_SSL,
|
|
13
|
+
process.env.MINIO_ACCESS_KEY,
|
|
14
|
+
process.env.MINIO_SECRET_KEY,
|
|
15
|
+
process.env.MINIO_BUCKET_NAME,
|
|
16
|
+
);
|
|
17
|
+
const client = new Client({
|
|
18
|
+
endPoint: process.env.MINIO_ENDPOINT,
|
|
19
|
+
useSSL: process.env.MINIO_USE_SSL === 'true',
|
|
20
|
+
accessKey: process.env.MINIO_ACCESS_KEY,
|
|
21
|
+
secretKey: process.env.MINIO_SECRET_KEY,
|
|
22
|
+
});
|
|
23
|
+
const configOssService = new ConfigOssService({
|
|
24
|
+
client,
|
|
25
|
+
bucketName: process.env.MINIO_BUCKET_NAME,
|
|
26
|
+
owner: 'admin',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const main = async () => {
|
|
30
|
+
configOssService.setPrefix('root');
|
|
31
|
+
const config = await configOssService.statObject('avatar.png');
|
|
32
|
+
console.log(config);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
main();
|
|
36
|
+
const putJson = async () => {
|
|
37
|
+
const config = await configOssService.putObject(
|
|
38
|
+
'a.json',
|
|
39
|
+
{
|
|
40
|
+
a: 'a',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
'content-type': 'application/json',
|
|
44
|
+
'cache-control': 'no-cache',
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
console.log(config);
|
|
48
|
+
};
|
|
49
|
+
// putJson();
|
|
50
|
+
const downloadMain = async () => {
|
|
51
|
+
const stat = await configOssService.statObject('a.json'); // 582af9ef5cdc53d6628f45cb842f874a
|
|
52
|
+
console.log(stat);
|
|
53
|
+
const objectStream = await downloadObject({
|
|
54
|
+
objectName: 'a.json',
|
|
55
|
+
client: configOssService,
|
|
56
|
+
filePath: path.resolve(cwd, 'a.json'),
|
|
57
|
+
});
|
|
58
|
+
// console.log(objectStream);
|
|
59
|
+
};
|
|
60
|
+
// downloadMain();
|
|
61
|
+
|
|
62
|
+
const statInfo = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const stat = await configOssService.statObject('a.json');
|
|
65
|
+
// configOssService.setPrefix('root/');
|
|
66
|
+
// const stat = await configOssService.statObject('avatar.png');
|
|
67
|
+
// const stat = await configOssService.statObject('center/0.0.1/index.html');
|
|
68
|
+
console.log(stat);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.code === 'NotFound') {
|
|
71
|
+
console.log('not found');
|
|
72
|
+
} else {
|
|
73
|
+
console.log('error', e);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
// statInfo();
|
|
78
|
+
|
|
79
|
+
const listObjects = async () => {
|
|
80
|
+
configOssService.setPrefix('root/avatar.png');
|
|
81
|
+
const list = await configOssService.listObjects('');
|
|
82
|
+
console.log(list);
|
|
83
|
+
};
|
|
84
|
+
// listObjects();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { OssBase } from "@/s3/core.ts";
|
|
2
|
+
|
|
3
|
+
import { s3Client, bucketName } from './common.ts';
|
|
4
|
+
|
|
5
|
+
const oss = new OssBase({
|
|
6
|
+
client: s3Client,
|
|
7
|
+
bucketName: bucketName,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// const list = await oss.listObjects('');
|
|
11
|
+
|
|
12
|
+
// console.log(list);
|
|
13
|
+
|
|
14
|
+
// const obj = await oss.getObjectAsString('readme.md');
|
|
15
|
+
// console.log(obj);
|
|
16
|
+
|
|
17
|
+
let putJson = {
|
|
18
|
+
name: 'test',
|
|
19
|
+
age: 18,
|
|
20
|
+
}
|
|
21
|
+
const objPut = await oss.putObject('a.json', putJson)
|
|
22
|
+
|
|
23
|
+
console.log(objPut);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ServerResponse } from 'node:http';
|
|
2
|
+
import { BucketItemStat } from 'minio';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const viewableExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'mp4', 'webm', 'mp3', 'wav', 'ogg', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
|
7
|
+
import { OssBase } from '../index.ts';
|
|
8
|
+
/**
|
|
9
|
+
* 过滤 metaData 中的 key, 去除 password, accesskey, secretkey,
|
|
10
|
+
* 并返回过滤后的 metaData
|
|
11
|
+
* @param metaData
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
export const filterMetaDataKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
|
|
15
|
+
const keys = Object.keys(metaData);
|
|
16
|
+
// remove X-Amz- meta data
|
|
17
|
+
const removeKeys = ['password', 'accesskey', 'secretkey', ...clearKeys];
|
|
18
|
+
const filteredKeys = keys.filter((key) => !removeKeys.includes(key));
|
|
19
|
+
return filteredKeys.reduce((acc, key) => {
|
|
20
|
+
acc[key] = metaData[key];
|
|
21
|
+
return acc;
|
|
22
|
+
}, {} as Record<string, string>);
|
|
23
|
+
};
|
|
24
|
+
type SendObjectOptions = {
|
|
25
|
+
res: ServerResponse;
|
|
26
|
+
client: OssBase;
|
|
27
|
+
objectName: string;
|
|
28
|
+
isDownload?: boolean;
|
|
29
|
+
};
|
|
30
|
+
export const NotFoundFile = (res: ServerResponse, msg?: string, code = 404) => {
|
|
31
|
+
res.writeHead(code, { 'Content-Type': 'text/plain' });
|
|
32
|
+
res.end(msg || 'Not Found File');
|
|
33
|
+
return;
|
|
34
|
+
};
|
|
35
|
+
export const sendObject = async ({ res, objectName, client, isDownload = false }: SendObjectOptions) => {
|
|
36
|
+
let stat: BucketItemStat;
|
|
37
|
+
try {
|
|
38
|
+
stat = await client.statObject(objectName);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
} finally {
|
|
41
|
+
if (!stat || stat.size === 0) {
|
|
42
|
+
return NotFoundFile(res);
|
|
43
|
+
}
|
|
44
|
+
const contentLength = stat.size;
|
|
45
|
+
const etag = stat.etag;
|
|
46
|
+
const lastModified = stat.lastModified.toISOString();
|
|
47
|
+
const filename = objectName.split('/').pop() || 'no-file-name-download'; // Extract filename from objectName
|
|
48
|
+
const fileExtension = filename.split('.').pop()?.toLowerCase() || '';
|
|
49
|
+
const filteredMetaData = filterMetaDataKeys(stat.metaData, ['size', 'etag', 'last-modified']);
|
|
50
|
+
const contentDisposition = viewableExtensions.includes(fileExtension) && !isDownload ? 'inline' : `attachment; filename="${filename}"`;
|
|
51
|
+
|
|
52
|
+
res.writeHead(200, {
|
|
53
|
+
'Content-Length': contentLength,
|
|
54
|
+
etag,
|
|
55
|
+
'last-modified': lastModified,
|
|
56
|
+
'Content-Disposition': contentDisposition,
|
|
57
|
+
...filteredMetaData,
|
|
58
|
+
});
|
|
59
|
+
const objectStream = await client.getObject(objectName);
|
|
60
|
+
|
|
61
|
+
objectStream.pipe(res, { end: true });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const downloadObject = async ({ objectName, client, filePath }: Pick<SendObjectOptions, 'objectName' | 'client'> & { filePath: string }) => {
|
|
66
|
+
const objectStream = await client.getObject(objectName);
|
|
67
|
+
const dir = path.dirname(filePath);
|
|
68
|
+
if (!fs.existsSync(dir)) {
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
objectStream.pipe(fs.createWriteStream(filePath));
|
|
72
|
+
return objectStream;
|
|
73
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const standardHeaderKeys = ['content-type', 'cache-control', 'content-disposition', 'content-encoding', 'content-language', 'expires'];
|
|
2
|
+
|
|
3
|
+
export type StandardHeaders = {
|
|
4
|
+
ContentType?: string;
|
|
5
|
+
CacheControl?: string;
|
|
6
|
+
ContentDisposition?: string;
|
|
7
|
+
ContentEncoding?: string;
|
|
8
|
+
ContentLanguage?: string;
|
|
9
|
+
Expires?: Date;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 从元数据中提取标准头部和自定义元数据
|
|
14
|
+
* @param metaData 原始元数据
|
|
15
|
+
* @returns 标准头部和自定义元数据
|
|
16
|
+
*/
|
|
17
|
+
export function extractStandardHeaders(metaData: Record<string, string>): {
|
|
18
|
+
standardHeaders: StandardHeaders;
|
|
19
|
+
customMetadata: Record<string, string>;
|
|
20
|
+
} {
|
|
21
|
+
const standardHeaders: StandardHeaders = {};
|
|
22
|
+
const customMetadata: Record<string, string> = {};
|
|
23
|
+
|
|
24
|
+
for (const [key, value] of Object.entries(metaData)) {
|
|
25
|
+
const lowerKey = key.toLowerCase();
|
|
26
|
+
if (lowerKey === 'content-type') {
|
|
27
|
+
standardHeaders.ContentType = value;
|
|
28
|
+
} else if (lowerKey === 'cache-control') {
|
|
29
|
+
standardHeaders.CacheControl = value;
|
|
30
|
+
} else if (lowerKey === 'content-disposition') {
|
|
31
|
+
standardHeaders.ContentDisposition = value;
|
|
32
|
+
} else if (lowerKey === 'content-encoding') {
|
|
33
|
+
standardHeaders.ContentEncoding = value;
|
|
34
|
+
} else if (lowerKey === 'content-language') {
|
|
35
|
+
standardHeaders.ContentLanguage = value;
|
|
36
|
+
} else if (lowerKey === 'expires') {
|
|
37
|
+
standardHeaders.Expires = new Date(value);
|
|
38
|
+
} else {
|
|
39
|
+
customMetadata[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { standardHeaders, customMetadata };
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
// 获取文件的 content-type
|
|
3
|
+
export const getContentType = (filePath: string) => {
|
|
4
|
+
const extname = path.extname(filePath);
|
|
5
|
+
const contentType = {
|
|
6
|
+
'.html': 'text/html; charset=utf-8',
|
|
7
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
8
|
+
'.css': 'text/css; charset=utf-8',
|
|
9
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
10
|
+
'.json': 'application/json; charset=utf-8',
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpg',
|
|
13
|
+
'.gif': 'image/gif',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.wav': 'audio/wav',
|
|
16
|
+
'.mp4': 'video/mp4',
|
|
17
|
+
'.md': 'text/markdown; charset=utf-8', // utf-8配置
|
|
18
|
+
'.ico': 'image/x-icon', // Favicon 图标
|
|
19
|
+
'.webp': 'image/webp', // WebP 图像格式
|
|
20
|
+
'.webm': 'video/webm', // WebM 视频格式
|
|
21
|
+
'.ogg': 'audio/ogg', // Ogg 音频格式
|
|
22
|
+
'.mp3': 'audio/mpeg', // MP3 音频格式
|
|
23
|
+
'.m4a': 'audio/mp4', // M4A 音频格式
|
|
24
|
+
'.m3u8': 'application/vnd.apple.mpegurl', // HLS 播放列表
|
|
25
|
+
'.ts': 'video/mp2t', // MPEG Transport Stream
|
|
26
|
+
'.pdf': 'application/pdf', // PDF 文档
|
|
27
|
+
'.doc': 'application/msword', // Word 文档
|
|
28
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word 文档 (新版)
|
|
29
|
+
'.ppt': 'application/vnd.ms-powerpoint', // PowerPoint 演示文稿
|
|
30
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PowerPoint (新版)
|
|
31
|
+
'.xls': 'application/vnd.ms-excel', // Excel 表格
|
|
32
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // Excel 表格 (新版)
|
|
33
|
+
'.csv': 'text/csv; charset=utf-8', // CSV 文件
|
|
34
|
+
'.xml': 'application/xml; charset=utf-8', // XML 文件
|
|
35
|
+
'.rtf': 'application/rtf', // RTF 文本文件
|
|
36
|
+
'.eot': 'application/vnd.ms-fontobject', // Embedded OpenType 字体
|
|
37
|
+
'.ttf': 'font/ttf', // TrueType 字体
|
|
38
|
+
'.woff': 'font/woff', // Web Open Font Format 1.0
|
|
39
|
+
'.woff2': 'font/woff2', // Web Open Font Format 2.0
|
|
40
|
+
'.otf': 'font/otf', // OpenType 字体
|
|
41
|
+
'.wasm': 'application/wasm', // WebAssembly 文件
|
|
42
|
+
'.pem': 'application/x-pem-file', // PEM 证书文件
|
|
43
|
+
'.crt': 'application/x-x509-ca-cert', // CRT 证书文件
|
|
44
|
+
'.yaml': 'application/x-yaml; charset=utf-8', // YAML 文件
|
|
45
|
+
'.yml': 'application/x-yaml; charset=utf-8', // YAML 文件(别名)
|
|
46
|
+
'.zip': 'application/octet-stream',
|
|
47
|
+
};
|
|
48
|
+
return contentType[extname] || 'application/octet-stream';
|
|
49
|
+
};
|
package/src/util/hash.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
// 582af9ef5cdc53d6628f45cb842f874a
|
|
3
|
+
// const hashStr = '{"a":"a"}';
|
|
4
|
+
// const hash = crypto.createHash('md5').update(hashStr).digest('hex');
|
|
5
|
+
// console.log(hash);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 计算字符串的md5值
|
|
9
|
+
* @param str
|
|
10
|
+
* @returns
|
|
11
|
+
*/
|
|
12
|
+
export const hash = (str: string | Buffer | Object) => {
|
|
13
|
+
let hashStr: string | Buffer;
|
|
14
|
+
if (str instanceof Buffer) {
|
|
15
|
+
hashStr = str;
|
|
16
|
+
} else if (str instanceof Object) {
|
|
17
|
+
hashStr = JSON.stringify(str, null, 2);
|
|
18
|
+
} else {
|
|
19
|
+
hashStr = str;
|
|
20
|
+
}
|
|
21
|
+
return crypto.createHash('md5').update(hashStr).digest('hex');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const hashSringify = (str: Object) => {
|
|
25
|
+
return JSON.stringify(str, null, 2);
|
|
26
|
+
};
|