@jayfong/x-utils 3.3.0 → 3.4.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.
@@ -0,0 +1,208 @@
1
+ import { isPlainObject } from "vtils";
2
+ // src/db_value.ts
3
+ /**
4
+ * 数据库字段值的序列化与反序列化工具。
5
+ *
6
+ * 适用于把数组或对象以字符串形式存储到数据库字段(如 VARCHAR / TEXT)的场景,
7
+ * 内置三种存储格式:
8
+ * - {@link XDbValue.SimpleArray}:以分隔符连接的数组,支持前后缀与数字模式;
9
+ * - {@link XDbValue.JsonArray}:JSON 数组;
10
+ * - {@link XDbValue.JsonObject}:JSON 对象,支持读写转换钩子。
11
+ *
12
+ * 每种格式都提供 `add`(写入)、`get`(读取)、`update`(更新)三个方法:
13
+ * - `add` 返回序列化后的字符串,入参非法时使用默认值兜底;
14
+ * - `get` 将存储字符串反序列化为目标类型,空值返回空数组或空对象;
15
+ * - `update` 返回用于更新的字符串,入参非法时返回 `undefined`,便于调用方跳过该字段。
16
+ *
17
+ * @example
18
+ * const sa = new XDbValue.SimpleArray();
19
+ * sa.add(['a', 'b']);
20
+ * // 'a,b'
21
+ * sa.get('a,b');
22
+ * // ['a', 'b']
23
+ */
24
+ var XDbValue = class {
25
+ /**
26
+ * 分隔符数组格式:`'a,b'` ↔ `['a', 'b']`。
27
+ *
28
+ * @template T 数组元素类型,仅支持 `string` 或 `number`。
29
+ */
30
+ static SimpleArray = class {
31
+ /** 元素之间的分隔符。 */
32
+ separator;
33
+ /** 存储值的统一前后缀,空字符串表示不包裹。 */
34
+ affix;
35
+ /** 是否将数组元素转换为数字。 */
36
+ number;
37
+ /**
38
+ * @param options 配置项,详见 {@link XDbValueSimpleArrayOptions}。
39
+ */
40
+ constructor(options) {
41
+ this.separator = options?.separator || ",";
42
+ this.affix = options?.affix || "";
43
+ this.number = !!options?.number;
44
+ }
45
+ /**
46
+ * 将数组序列化为适合存储的字符串。
47
+ *
48
+ * - `value` 不是数组时,使用 `defaultValue` 兜底;
49
+ * - 过滤掉 `null`、`undefined` 与空字符串元素;
50
+ * - 结果非空且设置了 `affix` 时在首尾包裹前后缀;结果为空时返回空字符串(不包裹)。
51
+ *
52
+ * @param value 要序列化的数组。
53
+ * @param defaultValue `value` 非法时使用的兜底数组。
54
+ * @returns 序列化后的字符串。
55
+ *
56
+ * @example
57
+ * const sa = new XDbValue.SimpleArray();
58
+ * sa.add(['a', 'b']);
59
+ * // 'a,b'
60
+ */
61
+ add = (value, defaultValue = []) => {
62
+ const text = (!value || !Array.isArray(value) ? defaultValue : value.filter((v) => v != null && v !== "")).join(this.separator);
63
+ return !text || !this.affix ? text : `${this.affix}${text}${this.affix}`;
64
+ };
65
+ /**
66
+ * 将存储字符串反序列化为数组。
67
+ *
68
+ * - 值为空时返回空数组;
69
+ * - 设置了 `affix` 时,仅当值同时以该前后缀开头和结尾才会剥离,否则按原始文本解析;
70
+ * - 按 `separator` 分割并过滤掉空字符串;
71
+ * - `number` 为 `true` 时,将每个元素转换为数字。
72
+ *
73
+ * @param value 存储字符串。
74
+ * @returns 反序列化后的数组。
75
+ *
76
+ * @example
77
+ * const sa = new XDbValue.SimpleArray();
78
+ * sa.get('a,b');
79
+ * // ['a', 'b']
80
+ */
81
+ get = (value) => {
82
+ if (!value) return [];
83
+ const res = (this.affix && value.startsWith(this.affix) && value.endsWith(this.affix) ? value.slice(this.affix.length, -this.affix.length) : value).split(this.separator).filter((v) => v !== "");
84
+ return this.number ? res.map(Number) : res;
85
+ };
86
+ /**
87
+ * 将数组序列化为用于更新的字符串。
88
+ *
89
+ * 与 {@link add} 的区别在于:`value` 不是数组时返回 `undefined`,
90
+ * 便于调用方在更新数据库时跳过该字段;`value` 是数组但过滤后为空时返回空字符串。
91
+ *
92
+ * @param value 要序列化的数组。
93
+ * @returns 序列化后的字符串;`value` 不是数组时返回 `undefined`。
94
+ */
95
+ update = (value) => {
96
+ const text = !value || !Array.isArray(value) ? void 0 : value.filter((v) => v != null && v !== "").join(this.separator);
97
+ return !text || !this.affix ? text : `${this.affix}${text}${this.affix}`;
98
+ };
99
+ };
100
+ /**
101
+ * JSON 数组格式:`'[{"a":1}]'` ↔ `[{ a: 1 }]`。
102
+ *
103
+ * @template T 数组元素类型。
104
+ */
105
+ static JsonArray = class {
106
+ /**
107
+ * 将数组序列化为 JSON 字符串。
108
+ *
109
+ * @param value 要序列化的数组。
110
+ * @param defaultValue `value` 非法时使用的兜底数组。
111
+ * @returns JSON 字符串。
112
+ */
113
+ add = (value, defaultValue = []) => {
114
+ return JSON.stringify(!value || !Array.isArray(value) ? defaultValue : value);
115
+ };
116
+ /**
117
+ * 将 JSON 字符串反序列化为数组。
118
+ *
119
+ * - 值为空时返回空数组;
120
+ * - 值不是合法 JSON 时抛出 `SyntaxError`。
121
+ *
122
+ * @param value JSON 字符串。
123
+ * @returns 反序列化后的数组。
124
+ */
125
+ get = (value) => {
126
+ return !value ? [] : JSON.parse(value);
127
+ };
128
+ /**
129
+ * 将数组序列化为用于更新的 JSON 字符串。
130
+ *
131
+ * @param value 要序列化的数组。
132
+ * @returns JSON 字符串;`value` 不是数组时返回 `undefined`。
133
+ */
134
+ update = (value) => {
135
+ return !value || !Array.isArray(value) ? void 0 : JSON.stringify(value);
136
+ };
137
+ };
138
+ /**
139
+ * JSON 对象格式:`'{"name":"x"}'` ↔ `{ name: 'x' }`。
140
+ *
141
+ * 相比 {@link XDbValue.JsonArray},额外支持 {@link XDbValueJsonObjectOptions.transform}
142
+ * 转换钩子,适合在读写时统一处理日期、默认值等字段。
143
+ *
144
+ * @template T 对象值的类型。
145
+ */
146
+ static JsonObject = class {
147
+ /** 构造时传入的转换钩子配置。 */
148
+ options;
149
+ /** 是否配置了 add 转换钩子。 */
150
+ hasTransformAdd = false;
151
+ /** 是否配置了 get 转换钩子。 */
152
+ hasTransformGet = false;
153
+ /** 是否配置了 update 转换钩子。 */
154
+ hasTransformUpdate = false;
155
+ /**
156
+ * @param options 转换钩子配置,详见 {@link XDbValueJsonObjectOptions}。
157
+ */
158
+ constructor(options) {
159
+ this.options = options;
160
+ this.hasTransformAdd = !!options?.transform?.add;
161
+ this.hasTransformGet = !!options?.transform?.get;
162
+ this.hasTransformUpdate = !!options?.transform?.update;
163
+ }
164
+ /**
165
+ * 将对象序列化为 JSON 字符串。
166
+ *
167
+ * - 配置 `transform.add` 时,先对值做转换;
168
+ * - `value` 不是普通对象时,使用 `defaultValue` 兜底。
169
+ *
170
+ * @param value 要序列化的对象。
171
+ * @param defaultValue `value` 非法时使用的兜底对象。
172
+ * @returns JSON 字符串。
173
+ */
174
+ add = (value, defaultValue = {}) => {
175
+ if (value && this.hasTransformAdd) value = this.options.transform.add(value);
176
+ return JSON.stringify(!value || !isPlainObject(value) ? defaultValue : value);
177
+ };
178
+ /**
179
+ * 将 JSON 字符串反序列化为对象。
180
+ *
181
+ * - 值为空时返回空对象 `{}`;
182
+ * - 值不是合法 JSON 时抛出 `SyntaxError`;
183
+ * - 配置 `transform.get` 时对结果做转换,空值解析出的 `{}` 同样会经过该钩子。
184
+ *
185
+ * @param value JSON 字符串。
186
+ * @returns 反序列化后的对象。
187
+ */
188
+ get = (value) => {
189
+ let result = !value ? {} : JSON.parse(value);
190
+ if (result && this.hasTransformGet) result = this.options.transform.get(result);
191
+ return result;
192
+ };
193
+ /**
194
+ * 将对象序列化为用于更新的 JSON 字符串。
195
+ *
196
+ * - 配置 `transform.update` 时,先对值做转换;
197
+ * - `value` 不是普通对象时返回 `undefined`。
198
+ *
199
+ * @param value 要序列化的对象。
200
+ * @returns JSON 字符串;`value` 不是普通对象时返回 `undefined`。
201
+ */
202
+ update = (value) => {
203
+ if (value && this.hasTransformUpdate) value = this.options.transform.update(value);
204
+ return !value || !isPlainObject(value) ? void 0 : JSON.stringify(value);
205
+ };
206
+ };
207
+ };
208
+ export { XDbValue };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `XDispose` 的构造配置。
3
+ */
4
+ export interface XDisposeOptions {
5
+ /**
6
+ * 是否在进程退出时自动调用 `dispose()` 进行资源清理。
7
+ *
8
+ * - 默认 `false`:不注册退出钩子,仅支持手动调用 `dispose()` 或通过
9
+ * `await using` 触发 `Symbol.asyncDispose`;
10
+ * - 为 `true` 时:通过 `asyncExitHook` 注册异步退出钩子,进程因
11
+ * `SIGINT`、`SIGTERM`、`beforeExit` 等场景退出时自动执行清理,
12
+ * 清理总时长最多等待 3000ms,超时后由退出钩子强制结束进程。
13
+ */
14
+ disposeOnExit?: boolean;
15
+ }
16
+ /**
17
+ * 资源释放管理器。
18
+ *
19
+ * 用于集中管理一组同步/异步清理函数,并在退出时统一、可靠地释放,
20
+ * 适用于数据库连接、文件句柄、定时器、子进程等资源的生命周期管理。
21
+ *
22
+ * 主要行为:
23
+ * - 通过 `add()` 按注册顺序追加清理函数;
24
+ * - `dispose()` 幂等:首次调用按注册顺序依次执行全部清理函数,执行后
25
+ * 清空列表,之后再次调用直接返回,不会重复执行;
26
+ * - 清理函数逐个执行,单个抛错不会中断后续清理,全部执行完后将错误
27
+ * 以 `AggregateError` 汇总抛出(无错误则不抛);
28
+ * - 实现 `Symbol.asyncDispose`,可直接配合 `await using` 使用;
29
+ * - 开启 `disposeOnExit` 时自动注册进程退出钩子,退出时调用 `dispose()`。
30
+ *
31
+ * @example
32
+ * const dispose = new XDispose({ disposeOnExit: true });
33
+ *
34
+ * dispose.add(() => db.close());
35
+ * dispose.add(async () => {
36
+ * await fs.rm(tmpDir, { recursive: true, force: true });
37
+ * });
38
+ *
39
+ * // 进程退出时自动清理,也可手动触发
40
+ * await dispose.dispose();
41
+ */
42
+ export declare class XDispose {
43
+ private options?;
44
+ /** 已注册的清理函数列表,按注册顺序存放。 */
45
+ private disposes;
46
+ /** 是否已完成清理,保证 `dispose()` 幂等。 */
47
+ private disposed;
48
+ /**
49
+ * 创建资源释放管理器。
50
+ *
51
+ * @param options 构造配置;`disposeOnExit` 为 `true` 时立即注册
52
+ * 进程退出钩子(见 {@link XDisposeOptions})。
53
+ */
54
+ constructor(options?: XDisposeOptions | undefined);
55
+ /**
56
+ * 注册一个清理函数。
57
+ *
58
+ * 清理函数支持同步或异步返回值,`dispose()` 执行时会 `await` 其结果。
59
+ * 已调用过 `dispose()` 后再注册会抛错,因为此时资源已释放完毕。
60
+ *
61
+ * @param fn 清理函数,按注册顺序在 `dispose()` 中依次执行。
62
+ *
63
+ * @example
64
+ * dispose.add(() => server.close());
65
+ */
66
+ add(fn: () => any): void;
67
+ /**
68
+ * 释放所有已注册的资源。
69
+ *
70
+ * 幂等:首次调用按注册顺序依次执行全部清理函数并清空列表,之后的
71
+ * 调用直接返回。单个清理函数抛错不会中断后续执行,全部执行完后若
72
+ * 存在错误则以 `AggregateError` 汇总抛出。
73
+ *
74
+ * @returns 全部清理函数成功执行后 resolve;存在失败时 reject,
75
+ * 错误为聚合了全部失败项的 `AggregateError`。
76
+ */
77
+ dispose(): Promise<void>;
78
+ /**
79
+ * `await using` 支持。
80
+ *
81
+ * 等价于调用 `dispose()`,用于在 `using` 语句作用域结束时自动释放。
82
+ */
83
+ [Symbol.asyncDispose](): Promise<void>;
84
+ /**
85
+ * 进程级共享的释放管理器实例。
86
+ *
87
+ * 该实例已开启 `disposeOnExit`,进程退出时会自动执行注册到其上的全部
88
+ * 清理函数。适用于不希望手动创建并持有 `XDispose` 实例的场景,直接
89
+ * 通过 `XDispose.exit.add(...)` 注册清理函数即可,进程退出时统一释放。
90
+ *
91
+ * 注意:作为进程级单例,该实例被 `dispose()` 后无法再注册清理函数,
92
+ * 通常无需手动调用,进程退出时由退出钩子自动处理。
93
+ *
94
+ * @example
95
+ * XDispose.exit.add(() => db.close());
96
+ */
97
+ static exit: XDispose;
98
+ }
@@ -0,0 +1,107 @@
1
+ import { asyncExitHook } from "exit-hook";
2
+ // src/dispose.ts
3
+ /**
4
+ * 资源释放管理器。
5
+ *
6
+ * 用于集中管理一组同步/异步清理函数,并在退出时统一、可靠地释放,
7
+ * 适用于数据库连接、文件句柄、定时器、子进程等资源的生命周期管理。
8
+ *
9
+ * 主要行为:
10
+ * - 通过 `add()` 按注册顺序追加清理函数;
11
+ * - `dispose()` 幂等:首次调用按注册顺序依次执行全部清理函数,执行后
12
+ * 清空列表,之后再次调用直接返回,不会重复执行;
13
+ * - 清理函数逐个执行,单个抛错不会中断后续清理,全部执行完后将错误
14
+ * 以 `AggregateError` 汇总抛出(无错误则不抛);
15
+ * - 实现 `Symbol.asyncDispose`,可直接配合 `await using` 使用;
16
+ * - 开启 `disposeOnExit` 时自动注册进程退出钩子,退出时调用 `dispose()`。
17
+ *
18
+ * @example
19
+ * const dispose = new XDispose({ disposeOnExit: true });
20
+ *
21
+ * dispose.add(() => db.close());
22
+ * dispose.add(async () => {
23
+ * await fs.rm(tmpDir, { recursive: true, force: true });
24
+ * });
25
+ *
26
+ * // 进程退出时自动清理,也可手动触发
27
+ * await dispose.dispose();
28
+ */
29
+ var XDispose = class XDispose {
30
+ options;
31
+ /** 已注册的清理函数列表,按注册顺序存放。 */
32
+ disposes = [];
33
+ /** 是否已完成清理,保证 `dispose()` 幂等。 */
34
+ disposed = false;
35
+ /**
36
+ * 创建资源释放管理器。
37
+ *
38
+ * @param options 构造配置;`disposeOnExit` 为 `true` 时立即注册
39
+ * 进程退出钩子(见 {@link XDisposeOptions})。
40
+ */
41
+ constructor(options) {
42
+ this.options = options;
43
+ if (this.options?.disposeOnExit) asyncExitHook(() => this.dispose().catch((err) => {
44
+ console.error("进程退出清理失败", err);
45
+ }), { wait: 3e3 });
46
+ }
47
+ /**
48
+ * 注册一个清理函数。
49
+ *
50
+ * 清理函数支持同步或异步返回值,`dispose()` 执行时会 `await` 其结果。
51
+ * 已调用过 `dispose()` 后再注册会抛错,因为此时资源已释放完毕。
52
+ *
53
+ * @param fn 清理函数,按注册顺序在 `dispose()` 中依次执行。
54
+ *
55
+ * @example
56
+ * dispose.add(() => server.close());
57
+ */
58
+ add(fn) {
59
+ if (this.disposed) throw new Error("XDispose 已完成清理,不能再注册新的清理函数");
60
+ this.disposes.push(fn);
61
+ }
62
+ /**
63
+ * 释放所有已注册的资源。
64
+ *
65
+ * 幂等:首次调用按注册顺序依次执行全部清理函数并清空列表,之后的
66
+ * 调用直接返回。单个清理函数抛错不会中断后续执行,全部执行完后若
67
+ * 存在错误则以 `AggregateError` 汇总抛出。
68
+ *
69
+ * @returns 全部清理函数成功执行后 resolve;存在失败时 reject,
70
+ * 错误为聚合了全部失败项的 `AggregateError`。
71
+ */
72
+ async dispose() {
73
+ if (this.disposed) return;
74
+ this.disposed = true;
75
+ const errors = [];
76
+ for (const fn of this.disposes) try {
77
+ await fn();
78
+ } catch (err) {
79
+ errors.push(err);
80
+ }
81
+ this.disposes = [];
82
+ if (errors.length > 0) throw new AggregateError(errors, "XDispose 清理过程中发生错误");
83
+ }
84
+ /**
85
+ * `await using` 支持。
86
+ *
87
+ * 等价于调用 `dispose()`,用于在 `using` 语句作用域结束时自动释放。
88
+ */
89
+ async [Symbol.asyncDispose]() {
90
+ await this.dispose();
91
+ }
92
+ /**
93
+ * 进程级共享的释放管理器实例。
94
+ *
95
+ * 该实例已开启 `disposeOnExit`,进程退出时会自动执行注册到其上的全部
96
+ * 清理函数。适用于不希望手动创建并持有 `XDispose` 实例的场景,直接
97
+ * 通过 `XDispose.exit.add(...)` 注册清理函数即可,进程退出时统一释放。
98
+ *
99
+ * 注意:作为进程级单例,该实例被 `dispose()` 后无法再注册清理函数,
100
+ * 通常无需手动调用,进程退出时由退出钩子自动处理。
101
+ *
102
+ * @example
103
+ * XDispose.exit.add(() => db.close());
104
+ */
105
+ static exit = new XDispose({ disposeOnExit: true });
106
+ };
107
+ export { XDispose };
@@ -0,0 +1,167 @@
1
+ import { MsValue } from "vtils";
2
+ /**
3
+ * `XFileStore` 的初始化配置。
4
+ *
5
+ * 面向腾讯云 COS 等兼容 S3 协议的对象存储:通过预签名 URL 让客户端
6
+ * 直传文件,文件内容不经过业务服务器中转。
7
+ */
8
+ export interface XFileStoreOptions {
9
+ /** 对象存储访问密钥 ID(如腾讯云 SecretId)。 */
10
+ accessKey: string;
11
+ /** 对象存储访问密钥 Key(如腾讯云 SecretKey)。 */
12
+ secretKey: string;
13
+ /**
14
+ * 对象存储服务端点,可带或不带协议前缀。
15
+ *
16
+ * - 不含协议(如 `cos.ap-guangzhou.myqcloud.com`)时,自动补全为
17
+ * `https://`;
18
+ * - 已是完整 URL(如 `https://cos.ap-guangzhou.myqcloud.com`)时
19
+ * 原样保留。
20
+ */
21
+ endpoint: string;
22
+ /** 存储桶名称,例如 `image-311111`。 */
23
+ bucket: string;
24
+ /**
25
+ * 存储目录前缀,可为空字符串,表示上传到桶根目录。
26
+ *
27
+ * 构造时会去除首尾的 `/`,例如 `/images/` 会被规范化为 `images`。
28
+ */
29
+ dir: string;
30
+ /**
31
+ * 文件公开访问的基础地址(通常为 CDN 域名),可带或不带协议前缀。
32
+ *
33
+ * 构造时会去除末尾的 `/`;最终公开地址为
34
+ * `${cdnUrl}/${dir}/${fileName}`。
35
+ */
36
+ cdnUrl: string;
37
+ }
38
+ /**
39
+ * `getUploadUrl` 的入参。
40
+ */
41
+ export interface XFileStoreGetUploadUrlPayload {
42
+ /**
43
+ * 原始文件名,仅用于推导 MIME 类型与生成最终文件名。
44
+ *
45
+ * - `keepName` 为 `false`(默认)时,最终文件名会被替换为
46
+ * `${XRandom.cuid2()}.${扩展名}`,原始文件名只参与 MIME 推导;
47
+ * - `keepName` 为 `true` 时,最终文件名与原始文件名完全一致。
48
+ */
49
+ fileName: string;
50
+ /**
51
+ * 预签名 URL 的有效时长,需大于 0。
52
+ *
53
+ * 支持毫秒数字(如 `60000`)或时长字符串(如 `'1m'`、`'2h'`),
54
+ * 内部统一换算为秒后传给 AWS SDK。
55
+ */
56
+ ttl: MsValue;
57
+ /**
58
+ * 是否保留原始文件名。
59
+ *
60
+ * @default false 默认生成随机文件名,避免同名覆盖与外部可控路径写入。
61
+ */
62
+ keepName?: boolean;
63
+ }
64
+ /**
65
+ * `getUploadUrl` 的结果。
66
+ */
67
+ export interface XFileStoreGetUploadUrlResult {
68
+ /** 文件 MIME 类型,例如 `image/png`。 */
69
+ contentType: string;
70
+ /** 预签名上传地址,客户端可直接对其发起 PUT 上传。 */
71
+ uploadUrl: string;
72
+ /** 文件上传后的公开访问地址。 */
73
+ publicUrl: string;
74
+ }
75
+ /**
76
+ * `upload` 的入参。
77
+ */
78
+ export interface XFileStoreUploadPayload {
79
+ /** 文件名,语义与 `XFileStoreGetUploadUrlPayload.fileName` 一致。 */
80
+ fileName: string;
81
+ /** 文件二进制内容。 */
82
+ content: Buffer;
83
+ /**
84
+ * 是否保留原始文件名。
85
+ *
86
+ * @default false 默认生成随机文件名。
87
+ */
88
+ keepName?: boolean;
89
+ }
90
+ /**
91
+ * `upload` 的结果。
92
+ *
93
+ * 与 `getUploadUrl` 返回结构一致:上传完成后返回与预签名阶段相同的
94
+ * 内容类型、上传地址与公开访问地址。
95
+ */
96
+ export interface XFileStoreUploadResult extends XFileStoreGetUploadUrlResult {}
97
+ /**
98
+ * 基于 S3 协议对象存储(如腾讯云 COS)的文件直传工具。
99
+ *
100
+ * 提供两条链路:
101
+ * 1. `getUploadUrl`:只生成预签名上传地址,由客户端自行 PUT 上传;
102
+ * 2. `upload`:内部生成预签名地址后,直接上传 `Buffer` 内容。
103
+ *
104
+ * @example
105
+ * const fileStore = new XFileStore({
106
+ * accessKey: '...',
107
+ * secretKey: '...',
108
+ * endpoint: 'cos.ap-guangzhou.myqcloud.com',
109
+ * bucket: 'image-311111',
110
+ * dir: 'x',
111
+ * cdnUrl: 'cdn.example.com',
112
+ * });
113
+ *
114
+ * const { uploadUrl, publicUrl } = await fileStore.getUploadUrl({
115
+ * fileName: 'avatar.png',
116
+ * ttl: '10m',
117
+ * });
118
+ *
119
+ * @remarks
120
+ * - `endpoint`、`cdnUrl` 缺省协议时自动补全 `https://`,`dir` 去除首尾 `/`;
121
+ * - 随机文件名使用 24 位 CUID2,碰撞概率极低,适合作为对象 Key;
122
+ * - 无法识别的文件类型兜底为 `application/octet-stream`。
123
+ */
124
+ export declare class XFileStore {
125
+ private options;
126
+ private endpoint;
127
+ private dir;
128
+ private cdnUrl;
129
+ constructor(options: XFileStoreOptions);
130
+ /**
131
+ * 生成文件上传的预签名 URL。
132
+ *
133
+ * 处理流程:
134
+ * 1. 依据 `fileName` 推断 MIME 类型,推断失败时兜底为
135
+ * `application/octet-stream`;
136
+ * 2. 依据 `keepName` 决定沿用原始文件名或生成随机文件名;
137
+ * 3. 拼接 `dir` 前缀作为对象 Key,并生成带签名的 PUT 地址;
138
+ * 4. 依据 `cdnUrl` 与 Key 组装公开访问地址。
139
+ *
140
+ * @param payload 上传参数,见 {@link XFileStoreGetUploadUrlPayload}。
141
+ * @returns 内容类型、预签名上传地址与公开访问地址。
142
+ *
143
+ * @example
144
+ * const { uploadUrl, publicUrl } = await fileStore.getUploadUrl({
145
+ * fileName: 'avatar.png',
146
+ * ttl: '10m',
147
+ * });
148
+ */
149
+ getUploadUrl(payload: XFileStoreGetUploadUrlPayload): Promise<XFileStoreGetUploadUrlResult>;
150
+ /**
151
+ * 生成预签名 URL 并直接上传文件内容。
152
+ *
153
+ * 先复用 {@link XFileStore.getUploadUrl} 生成预签名地址(默认有效期
154
+ * 1 分钟),再以 PUT 方式将 `content` 上传到该地址,最后返回与
155
+ * `getUploadUrl` 一致的结果。
156
+ *
157
+ * @param payload 上传参数,见 {@link XFileStoreUploadPayload}。
158
+ * @returns 内容类型、预签名上传地址与公开访问地址。
159
+ *
160
+ * @example
161
+ * const result = await fileStore.upload({
162
+ * fileName: 'avatar.png',
163
+ * content: Buffer.from('...'),
164
+ * });
165
+ */
166
+ upload(payload: XFileStoreUploadPayload): Promise<XFileStoreUploadResult>;
167
+ }
@@ -0,0 +1,120 @@
1
+ import { XRandom } from "./random.mjs";
2
+ import { xr } from "@jayfong/x-request";
3
+ import { isUrl, ms } from "vtils";
4
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
5
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
6
+ import mime from "mime";
7
+ // src/file_store.ts
8
+ /**
9
+ * 基于 S3 协议对象存储(如腾讯云 COS)的文件直传工具。
10
+ *
11
+ * 提供两条链路:
12
+ * 1. `getUploadUrl`:只生成预签名上传地址,由客户端自行 PUT 上传;
13
+ * 2. `upload`:内部生成预签名地址后,直接上传 `Buffer` 内容。
14
+ *
15
+ * @example
16
+ * const fileStore = new XFileStore({
17
+ * accessKey: '...',
18
+ * secretKey: '...',
19
+ * endpoint: 'cos.ap-guangzhou.myqcloud.com',
20
+ * bucket: 'image-311111',
21
+ * dir: 'x',
22
+ * cdnUrl: 'cdn.example.com',
23
+ * });
24
+ *
25
+ * const { uploadUrl, publicUrl } = await fileStore.getUploadUrl({
26
+ * fileName: 'avatar.png',
27
+ * ttl: '10m',
28
+ * });
29
+ *
30
+ * @remarks
31
+ * - `endpoint`、`cdnUrl` 缺省协议时自动补全 `https://`,`dir` 去除首尾 `/`;
32
+ * - 随机文件名使用 24 位 CUID2,碰撞概率极低,适合作为对象 Key;
33
+ * - 无法识别的文件类型兜底为 `application/octet-stream`。
34
+ */
35
+ var XFileStore = class {
36
+ options;
37
+ endpoint = "";
38
+ dir = "";
39
+ cdnUrl = "";
40
+ constructor(options) {
41
+ this.options = options;
42
+ this.endpoint = isUrl(this.options.endpoint) ? this.options.endpoint : `https://${this.options.endpoint}`;
43
+ this.dir = this.options.dir ? `${this.options.dir.replace(/^\/+/, "").replace(/\/+$/, "")}` : "";
44
+ this.cdnUrl = (isUrl(this.options.cdnUrl) ? this.options.cdnUrl : `https://${this.options.cdnUrl}`).replace(/\/+$/, "");
45
+ }
46
+ /**
47
+ * 生成文件上传的预签名 URL。
48
+ *
49
+ * 处理流程:
50
+ * 1. 依据 `fileName` 推断 MIME 类型,推断失败时兜底为
51
+ * `application/octet-stream`;
52
+ * 2. 依据 `keepName` 决定沿用原始文件名或生成随机文件名;
53
+ * 3. 拼接 `dir` 前缀作为对象 Key,并生成带签名的 PUT 地址;
54
+ * 4. 依据 `cdnUrl` 与 Key 组装公开访问地址。
55
+ *
56
+ * @param payload 上传参数,见 {@link XFileStoreGetUploadUrlPayload}。
57
+ * @returns 内容类型、预签名上传地址与公开访问地址。
58
+ *
59
+ * @example
60
+ * const { uploadUrl, publicUrl } = await fileStore.getUploadUrl({
61
+ * fileName: 'avatar.png',
62
+ * ttl: '10m',
63
+ * });
64
+ */
65
+ async getUploadUrl(payload) {
66
+ const s3Client = new S3Client({
67
+ credentials: {
68
+ accessKeyId: this.options.accessKey,
69
+ secretAccessKey: this.options.secretKey
70
+ },
71
+ endpoint: this.endpoint,
72
+ region: "x"
73
+ });
74
+ const contentType = mime.getType(payload.fileName) || "application/octet-stream";
75
+ const extension = mime.getExtension(contentType) || "bin";
76
+ const fileName = payload.keepName ? payload.fileName : `${XRandom.cuid2()}.${extension}`;
77
+ const dir = this.dir ? `${this.dir}/` : "";
78
+ const command = new PutObjectCommand({
79
+ Bucket: this.options.bucket,
80
+ Key: `${dir}${fileName}`,
81
+ ContentType: contentType
82
+ });
83
+ return {
84
+ contentType,
85
+ uploadUrl: await getSignedUrl(s3Client, command, { expiresIn: ms(payload.ttl, true) }),
86
+ publicUrl: `${this.cdnUrl}/${dir}${fileName}`
87
+ };
88
+ }
89
+ /**
90
+ * 生成预签名 URL 并直接上传文件内容。
91
+ *
92
+ * 先复用 {@link XFileStore.getUploadUrl} 生成预签名地址(默认有效期
93
+ * 1 分钟),再以 PUT 方式将 `content` 上传到该地址,最后返回与
94
+ * `getUploadUrl` 一致的结果。
95
+ *
96
+ * @param payload 上传参数,见 {@link XFileStoreUploadPayload}。
97
+ * @returns 内容类型、预签名上传地址与公开访问地址。
98
+ *
99
+ * @example
100
+ * const result = await fileStore.upload({
101
+ * fileName: 'avatar.png',
102
+ * content: Buffer.from('...'),
103
+ * });
104
+ */
105
+ async upload(payload) {
106
+ const res = await this.getUploadUrl({
107
+ fileName: payload.fileName,
108
+ ttl: ms("1m"),
109
+ keepName: payload.keepName
110
+ });
111
+ await xr.post({
112
+ url: res.uploadUrl,
113
+ method: "PUT",
114
+ headers: { "Content-Type": res.contentType },
115
+ data: payload.content
116
+ });
117
+ return res;
118
+ }
119
+ };
120
+ export { XFileStore };