@lhy-meta-server/meta-oss 0.0.0-stage → 0.0.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -3
  3. package/dist/ali-oss/client.d.ts +185 -0
  4. package/dist/ali-oss/client.d.ts.map +1 -0
  5. package/dist/ali-oss/driver.d.ts +156 -0
  6. package/dist/ali-oss/driver.d.ts.map +1 -0
  7. package/dist/ali-oss/index.d.ts +11 -0
  8. package/dist/ali-oss/index.d.ts.map +1 -0
  9. package/dist/ali-oss/index.js +3 -0
  10. package/dist/ali-oss/index.js.map +11 -0
  11. package/dist/ali-oss/mapping.d.ts +25 -0
  12. package/dist/ali-oss/mapping.d.ts.map +1 -0
  13. package/dist/core/driver.d.ts +194 -0
  14. package/dist/core/driver.d.ts.map +1 -0
  15. package/dist/core/errors.d.ts +106 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/execution.d.ts +130 -0
  18. package/dist/core/execution.d.ts.map +1 -0
  19. package/dist/core/keys.d.ts +34 -0
  20. package/dist/core/keys.d.ts.map +1 -0
  21. package/dist/core/mime.d.ts +35 -0
  22. package/dist/core/mime.d.ts.map +1 -0
  23. package/dist/core/observability.d.ts +224 -0
  24. package/dist/core/observability.d.ts.map +1 -0
  25. package/dist/core/payload.d.ts +81 -0
  26. package/dist/core/payload.d.ts.map +1 -0
  27. package/dist/core/policy.d.ts +31 -0
  28. package/dist/core/policy.d.ts.map +1 -0
  29. package/dist/core/storage.d.ts +344 -0
  30. package/dist/core/storage.d.ts.map +1 -0
  31. package/dist/core/types.d.ts +301 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/validation.d.ts +73 -0
  34. package/dist/core/validation.d.ts.map +1 -0
  35. package/dist/elysia/index.d.ts +81 -0
  36. package/dist/elysia/index.d.ts.map +1 -0
  37. package/dist/elysia/index.js +3 -0
  38. package/dist/elysia/index.js.map +10 -0
  39. package/dist/filesystem/driver.d.ts +242 -0
  40. package/dist/filesystem/driver.d.ts.map +1 -0
  41. package/dist/filesystem/durability.d.ts +28 -0
  42. package/dist/filesystem/durability.d.ts.map +1 -0
  43. package/dist/filesystem/index.d.ts +11 -0
  44. package/dist/filesystem/index.d.ts.map +1 -0
  45. package/dist/filesystem/index.js +4 -0
  46. package/dist/filesystem/index.js.map +14 -0
  47. package/dist/filesystem/metadata.d.ts +56 -0
  48. package/dist/filesystem/metadata.d.ts.map +1 -0
  49. package/dist/filesystem/paths.d.ts +48 -0
  50. package/dist/filesystem/paths.d.ts.map +1 -0
  51. package/dist/filesystem/signer.d.ts +58 -0
  52. package/dist/filesystem/signer.d.ts.map +1 -0
  53. package/dist/index-5w45sfp6.js +4 -0
  54. package/dist/index-5w45sfp6.js.map +10 -0
  55. package/dist/index-7dpmhwry.js +4 -0
  56. package/dist/index-7dpmhwry.js.map +11 -0
  57. package/dist/index.d.ts +22 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +3 -0
  60. package/dist/index.js.map +16 -0
  61. package/package.json +81 -6
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meta-workspace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,378 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @lhy-meta-server/meta-oss
2
+
3
+ 面向 Bun 与 Node.js 的生产级对象存储抽象。一套 API 同时支持本地文件系统与阿里云 OSS,并可通过适配器以极少代码接入任意第三方对象存储;附带 Elysia 注入插件。
4
+
5
+ - **统一抽象**:业务只依赖 `ObjectStorage`,切换后端只需更换 `driver`。
6
+ - **内置本地存储**:原子写入、路径穿越与符号链接防护、分片上传、HMAC 签名 URL。
7
+ - **零配置 ali-oss**:直接传入 ali-oss 客户端实例即可,本包不依赖 `ali-oss`。
8
+ - **生产可靠性**:超时、取消、指数退避重试、并发控制、上传大小/MIME/扩展名限制、稳定错误码。
9
+ - **可观测**:结构化事件、重试事件与进程内指标,事件中不包含凭据、签名或签名 URL。
10
+ - **运行时兼容**:Bun ≥ 1.3、Node.js ≥ 20,纯 ESM,附带完整类型声明。
11
+
12
+ ## 目录
13
+
14
+ - [安装](#安装)
15
+ - [快速开始](#快速开始)
16
+ - [Elysia 集成](#elysia-集成)
17
+ - [本地文件系统](#本地文件系统)
18
+ - [阿里云 OSS](#阿里云-oss)
19
+ - [接入其他对象存储](#接入其他对象存储)
20
+ - [上传策略与安全](#上传策略与安全)
21
+ - [错误处理](#错误处理)
22
+ - [API](#api)
23
+ - [可观测性](#可观测性)
24
+ - [测试](#测试)
25
+ - [License](#license)
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ bun add @lhy-meta-server/meta-oss
31
+ # 或
32
+ npm install @lhy-meta-server/meta-oss
33
+ ```
34
+
35
+ 按需安装可选依赖:
36
+
37
+ ```bash
38
+ bun add elysia # 使用 Elysia 插件时
39
+ bun add ali-oss # 使用阿里云 OSS 时
40
+ ```
41
+
42
+ ## 快速开始
43
+
44
+ ```ts
45
+ import { ObjectStorage, createObjectKey } from '@lhy-meta-server/meta-oss'
46
+ import { FileSystemDriver } from '@lhy-meta-server/meta-oss/filesystem'
47
+
48
+ const storage = new ObjectStorage({
49
+ driver: new FileSystemDriver({ root: './storage' }),
50
+ upload: { maxSize: 10 * 1024 * 1024, allowedContentTypes: ['image/*', 'application/pdf'] },
51
+ })
52
+
53
+ const key = createObjectKey('头像.png', { directory: 'avatars' })
54
+ // avatars/2026/10/13/1b9d...e2.png
55
+
56
+ await storage.put(key, file) // File / Blob / Uint8Array / string / ReadableStream / Node.js Readable
57
+ const text = await storage.getText('notes/readme.txt')
58
+ const { info, body } = await storage.get(key) // body 为 Web ReadableStream
59
+ await storage.delete(key)
60
+ ```
61
+
62
+ ## Elysia 集成
63
+
64
+ 插件只通过 `decorate` 注入服务并管理生命周期,**不注册任何路由**,路由与鉴权由业务自行实现。
65
+
66
+ ```ts
67
+ import { Elysia, t } from 'elysia'
68
+ import { oss } from '@lhy-meta-server/meta-oss/elysia'
69
+
70
+ const app = new Elysia()
71
+ .use(oss({ storage }))
72
+ .post('/avatars', async ({ oss, body }) => oss.put(`avatars/${crypto.randomUUID()}.png`, body.file), {
73
+ body: t.Object({ file: t.File() }),
74
+ })
75
+ .get('/files/*', async ({ oss, params, set }) => {
76
+ const { info, body } = await oss.get(params['*'])
77
+ set.headers['content-type'] = info.contentType
78
+ return new Response(body)
79
+ })
80
+ ```
81
+
82
+ | 选项 | 类型 | 默认值 | 说明 |
83
+ | --- | --- | --- | --- |
84
+ | `storage` | `ObjectStorage` | 必填 | 注入的服务实例 |
85
+ | `propertyName` | `string` | `'oss'` | Context 中的属性名 |
86
+ | `name` | `string` | `@lhy-meta-server/meta-oss:<propertyName>` | 插件去重名称 |
87
+ | `closeOnStop` | `boolean` | `true` | 应用停止时关闭存储 |
88
+
89
+ 同一应用可注册多个存储,只需使用不同的 `propertyName`:
90
+
91
+ ```ts
92
+ app.use(oss({ storage: publicStorage })).use(oss({ storage: archiveStorage, propertyName: 'archive' }))
93
+ ```
94
+
95
+ ## 本地文件系统
96
+
97
+ ```ts
98
+ import { FileSystemDriver } from '@lhy-meta-server/meta-oss/filesystem'
99
+
100
+ const driver = new FileSystemDriver({
101
+ root: '/data/oss',
102
+ signing: { secret: config.ossSigningSecret, baseUrl: 'https://api.example.com/files' },
103
+ })
104
+ ```
105
+
106
+ | 选项 | 类型 | 默认值 | 说明 |
107
+ | --- | --- | --- | --- |
108
+ | `root` | `string` | 必填 | 存储根目录,不存在时自动创建 |
109
+ | `signing.secret` | `string` | — | HMAC-SHA256 密钥,至少 32 个字符 |
110
+ | `signing.baseUrl` | `string` | — | 签名 URL 的基础地址 |
111
+ | `directoryMode` | `number` | `0o750` | 新建目录权限 |
112
+ | `fileMode` | `number` | `0o640` | 新建文件权限 |
113
+ | `multipartTtl` | `number` | 24 小时 | 未完成分片会话的保留毫秒数 |
114
+ | `temporaryTtl` | `number` | 1 小时 | 崩溃残留临时文件的保留毫秒数,初始化时清理;应大于单次写入最长耗时 |
115
+ | `durable` | `boolean` | `true` | 是否对文件与目录 `fsync`;关闭可提升吞吐,但断电可能丢失最近写入 |
116
+
117
+ 实现要点:
118
+
119
+ - **原子写入**:内容先写入 `.meta-oss/tmp` 并 `fsync`,再原子 `rename`;`overwrite: false` 时使用 `link` 原子占位,不存在竞态覆盖。提交后同步父目录(Windows 等不支持目录 `fsync` 的平台自动跳过)。
120
+ - **内容与元数据一致**:元数据以内容文件的 inode 命名,并在内容提交前落盘。崩溃或并发写入同一键时,读到的内容、ETag 与元数据总是来自同一次写入。
121
+ - **读取一致**:`get` / `head` 先打开文件句柄再读取状态与元数据;句柄打开后对象被删除或覆盖,本次读取仍返回打开时刻的完整内容。
122
+ - **路径安全**:拒绝 `..`、绝对路径、盘符、反斜杠、控制字符、Windows 保留名,并在解析真实路径后再次确认未逃逸根目录;打开文件后以 inode 复核,消除检查与打开之间被替换为符号链接的竞态。
123
+ - **增量列举**:按目录逐层有序遍历,凑满一页即停止;设置分隔符时不展开被折叠的目录。每页只读取当前页对象的描述信息。
124
+ - **资源回收**:初始化时清理过期临时文件;新建分片会话时清理过期会话;取消写入时立即终止源流并删除临时文件。孤立元数据可在低峰期调用 `driver.cleanup()` 回收。
125
+ - **Windows**:被读取中的文件无法被替换,覆盖写入会短暂退避重试(约 1 秒),仍失败时返回可重试的 `BACKEND`;删除被读取中的文件返回 `ACCESS_DENIED`。
126
+ - **文件系统要求**:`overwrite: false` 依赖硬链接,FAT 与部分网络文件系统不支持。
127
+
128
+ ```ts
129
+ // 例如每天凌晨回收孤立元数据
130
+ const { temporary, metadata } = await driver.cleanup()
131
+ ```
132
+
133
+ ### 本地签名 URL
134
+
135
+ 插件不提供路由。签发后,在业务自建的路由中调用 `verifyUrl` 完成鉴权:
136
+
137
+ ```ts
138
+ const url = await storage.signUrl('reports/q3.pdf', { expiresIn: 300, responseContentDisposition: 'attachment' })
139
+
140
+ app.get('/files/*', async ({ oss, request, set }) => {
141
+ const { key, responseContentDisposition } = oss.verifyUrl({ url: request.url, method: request.method })
142
+ const { info, body } = await oss.get(key)
143
+ set.headers['content-type'] = info.contentType
144
+ if (responseContentDisposition) set.headers['content-disposition'] = responseContentDisposition
145
+ return new Response(body)
146
+ })
147
+ ```
148
+
149
+ 签名覆盖方法、对象键、过期时间、`Content-Type` 与 `Content-Disposition`,并使用常量时间比较。
150
+
151
+ ## 阿里云 OSS
152
+
153
+ 本包不依赖 `ali-oss`。业务自行安装并创建客户端,适配器只调用其稳定方法,凭据不会被读取或记录。
154
+
155
+ ```ts
156
+ import OSS from 'ali-oss'
157
+ import { ObjectStorage } from '@lhy-meta-server/meta-oss'
158
+ import { AliOssDriver } from '@lhy-meta-server/meta-oss/ali-oss'
159
+
160
+ const storage = new ObjectStorage({
161
+ driver: new AliOssDriver({
162
+ client: new OSS({ region: 'oss-cn-hangzhou', bucket: 'my-bucket', accessKeyId, accessKeySecret }),
163
+ }),
164
+ })
165
+ ```
166
+
167
+ | 选项 | 类型 | 说明 |
168
+ | --- | --- | --- |
169
+ | `client` | `AliOssClient` | ali-oss 客户端实例(必填) |
170
+ | `serverSideEncryption` | `string` | 服务器端加密算法,如 `AES256`、`KMS` |
171
+ | `storageClass` | `string` | 存储类型,如 `Standard`、`IA`、`Archive` |
172
+ | `bufferThreshold` | `number` | 8 MiB | 不超过该大小的内容整体上传(可重试),更大的内容流式上传,内存占用恒定 |
173
+
174
+ 能力映射:
175
+
176
+ | 服务 API | ali-oss 调用 |
177
+ | --- | --- |
178
+ | `put` | 不超过 `bufferThreshold` 的已知大小内容用 `put`;更大的内容(含 `Blob`)与流用 `putStream`,取消时销毁上传流;超过服务层 `multipartThreshold` 时自动分片 |
179
+ | `get` | `getStream`,范围读取使用 `Range` 头;打开阶段被取消时销毁响应流 |
180
+ | `head` / `exists` | `head` 获取类型与元数据;SDK 提供 `getObjectMeta` 时用其获取准确大小与 ETag(不受 gzip 影响) |
181
+ | `deleteMany` | `deleteMulti`(每批 1000 个) |
182
+ | `copy` / `move` | 服务端 `copy`,`overwrite: false` 时使用 `x-oss-forbid-overwrite` |
183
+ | `list` | `list`,游标映射为 `marker` |
184
+ | `signUrl` | 优先 `asyncSignatureUrl`(支持 STS 刷新),回退 `signatureUrl` |
185
+ | 分片上传 | `initMultipartUpload` / `uploadPart` / `completeMultipartUpload` / `abortMultipartUpload` |
186
+
187
+ 使用 STS 临时凭证时,按 ali-oss 文档配置 `refreshSTSToken` 即可,适配器无需感知。
188
+
189
+ ## 接入其他对象存储
190
+
191
+ 实现 `StorageDriver` 的四个必需方法即可获得全部服务能力;`deleteMany`、`copy` 缺失时服务层会自动降级,`list`、`signUrl`、`multipart` 缺失时抛出 `UNSUPPORTED`。
192
+
193
+ ```ts
194
+ import { defineDriver, ObjectStorage, StorageError } from '@lhy-meta-server/meta-oss'
195
+
196
+ const driver = defineDriver({
197
+ name: 'my-oss',
198
+ async put(key, payload, options) {
199
+ await sdk.upload(key, payload.stream(), { contentType: options.contentType, signal: options.signal })
200
+ return this.head(key, options)
201
+ },
202
+ async get(key, options) {
203
+ const response = await sdk.download(key, { signal: options.signal })
204
+ return { info: toInfo(key, response), body: response.body }
205
+ },
206
+ async head(key) {
207
+ return toInfo(key, await sdk.stat(key))
208
+ },
209
+ async delete(key) {
210
+ await sdk.remove(key)
211
+ },
212
+ })
213
+
214
+ const storage = new ObjectStorage({ driver })
215
+ ```
216
+
217
+ 适配器约定:
218
+
219
+ - 收到的对象键已校验并包含服务级 `prefix`。
220
+ - “对象不存在”应抛出 `NOT_FOUND`;SDK 抛出的 HTTP 404/403/409/5xx 等错误会被服务层自动归一化。
221
+ - `payload.replayable` 为 `false` 时内容只能读取一次,`payload.size` 可能为 `undefined`。
222
+ - `overwrite` 为 `false` 时应原子拒绝并抛出 `ALREADY_EXISTS`。
223
+ - 尽量把 `signal` 传给底层 I/O,以便及时释放资源。
224
+
225
+ ## API
226
+
227
+ ### `new ObjectStorage(options)`
228
+
229
+ | 选项 | 类型 | 默认值 | 说明 |
230
+ | --- | --- | --- | --- |
231
+ | `driver` | `StorageDriver` | 必填 | 存储适配器 |
232
+ | `prefix` | `string` | `''` | 自动添加到所有键前的目录前缀,对业务透明 |
233
+ | `timeout` | `number` | `30000` | 单次尝试超时毫秒数,`0` 表示不限制 |
234
+ | `retry.retries` | `number` | `2` | 最大重试次数(0–10) |
235
+ | `retry.baseDelay` | `number` | `100` | 首次重试等待毫秒数 |
236
+ | `retry.maxDelay` | `number` | `2000` | 单次等待上限 |
237
+ | `retry.jitter` | `boolean` | `true` | 是否叠加随机抖动 |
238
+ | `retry.shouldRetry` | `(error, attempt) => boolean` | 仅 `retryable` 错误 | 自定义重试判定 |
239
+ | `concurrency` | `number` | `32` | 对后端的最大在途请求数;超时/取消的请求在真正结束前仍占用额度 |
240
+ | `abortGrace` | `number` | `5000` | 超时或取消后等待底层操作结束的最长毫秒数,超过后放弃追踪并释放额度 |
241
+ | `multipartThreshold` | `number` | 64 MiB | 已知大小超过该值且适配器支持分片时,`put` 自动改用流式分片上传;`0` 禁用 |
242
+ | `upload` | `UploadPolicy` | `{}` | 上传限制,见下文 |
243
+ | `overwrite` | `boolean` | `true` | 同名对象默认是否覆盖 |
244
+ | `contentTypes` | `Record<string, string>` | `{}` | 自定义扩展名 → MIME 映射 |
245
+ | `partSize` | `number` | 8 MiB | 自动分片大小,不小于 100 KiB |
246
+ | `partConcurrency` | `number` | `4` | 自动分片并行数 |
247
+ | `observer` | `StorageObserver` | — | 结构化事件观察器 |
248
+ | `metrics` | `StorageMetrics` | 私有实例 | 指标收集器,可跨实例共享 |
249
+
250
+ 所有方法都接受 `signal`、`timeout`、`retries` 以覆盖单次调用的默认值。
251
+
252
+ | 方法 | 说明 |
253
+ | --- | --- |
254
+ | `put(key, body, options?)` | 写入对象,支持 `contentType`、`cacheControl`、`contentDisposition`、`metadata`、`overwrite` |
255
+ | `get(key, { range? })` | 流式读取,返回 `{ info, body, range? }` |
256
+ | `getBytes(key)` / `getText(key)` | 读取为字节或 UTF-8 文本 |
257
+ | `head(key)` / `exists(key)` | 读取描述信息 / 判断是否存在 |
258
+ | `delete(key)` | 删除对象,不存在时静默成功 |
259
+ | `deleteMany(keys)` | 批量删除,返回 `{ deleted, failed }` |
260
+ | `list({ prefix, delimiter, cursor, limit })` | 分页列举,返回 `{ objects, prefixes, cursor? }` |
261
+ | `listAll({ prefix })` | 自动分页的异步迭代器 |
262
+ | `copy(src, dst)` / `move(src, dst)` | 复制 / 移动,保留 HTTP 属性与元数据 |
263
+ | `signUrl(key, { method, expiresIn, contentType, responseContentDisposition })` | 生成 `GET`/`PUT` 签名 URL |
264
+ | `verifyUrl({ url, method, contentType })` | 校验本服务签发的签名 URL(自签名后端) |
265
+ | `uploadMultipart(key, body, { partSize, concurrency })` | 流式自动分片上传,失败时自动中止 |
266
+ | `createMultipart` / `uploadPart` / `completeMultipart` / `abortMultipart` | 手动分片,适用于断点续传 |
267
+ | `getMetrics()` / `resetMetrics()` | 读取 / 重置指标 |
268
+ | `close()` | 幂等关闭,之后的操作抛出 `CLOSED` |
269
+
270
+ ### 工具函数
271
+
272
+ - `createObjectKey(filename, { directory?, date? })`:生成 `目录/yyyy/mm/dd/<uuid>.<ext>` 形式的不可预测键,原始文件名只用于提取扩展名。
273
+ - `lookupContentType(key, overrides?)`:按扩展名推断 MIME。
274
+ - `assertKey(key)`:按服务规则校验对象键。
275
+
276
+ ### 重试语义
277
+
278
+ 只有幂等或内容可重复读取的操作才会重试:字符串、二进制与 `Blob` 写入可重试,流式写入、`move`、`createMultipart`、`completeMultipart` 只尝试一次。调用方取消后不再重试。
279
+
280
+ ### 超时与取消语义
281
+
282
+ - 超时或取消时调用方立即得到 `TIMEOUT` / `ABORTED`,同时把信号传给适配器以中断底层 I/O。
283
+ - 并发额度与下一次重试都会等到底层操作真正结束(最长 `abortGrace`),因此超时不会导致后台请求堆积,重试也不会与残留写入并发操作同一对象。
284
+ - 适配器因取消而抛出的次生错误(如连接中断)会统一报告为 `TIMEOUT` / `ABORTED`。
285
+ - `get` 的超时只作用于打开对象;返回的流由调用方消费,可通过 `body.cancel()` 提前释放连接或文件句柄。
286
+
287
+ ## 上传策略与安全
288
+
289
+ ```ts
290
+ new ObjectStorage({
291
+ driver,
292
+ upload: {
293
+ maxSize: 20 * 1024 * 1024,
294
+ allowedContentTypes: ['image/*', 'application/pdf'],
295
+ allowedExtensions: ['png', 'jpg', 'jpeg', 'webp', 'pdf'],
296
+ deniedExtensions: ['exe', 'sh', 'html', 'svg'],
297
+ },
298
+ })
299
+ ```
300
+
301
+ - 策略作用于 `put`、分片上传与 `PUT` 签名 URL;流式内容在读取过程中检查大小,超限即中止。
302
+ - `PUT` 签名 URL 无法在服务端限制客户端上传的字节数,请结合网关或回调校验。
303
+ - 对象键允许中文等 Unicode 字符,最长 1023 字节;拒绝空段、`.`、`..`、前导 `/`、盘符、反斜杠与控制字符。
304
+ - 元数据键仅允许小写字母、数字与连字符,总大小不超过 8 KiB;HTTP 属性拒绝控制字符,防止头部注入。
305
+ - 凭据只能通过构造参数传入,本包不读取任何环境变量。
306
+
307
+ ## 错误处理
308
+
309
+ 所有错误都是 `StorageError`,通过稳定的 `code` 判断:
310
+
311
+ | `code` | 含义 | 默认可重试 |
312
+ | --- | --- | --- |
313
+ | `INVALID_KEY` | 对象键非法 | 否 |
314
+ | `INVALID_OPTION` | 参数非法 | 否 |
315
+ | `NOT_FOUND` | 对象或分片会话不存在 | 否 |
316
+ | `ALREADY_EXISTS` | 禁止覆盖时目标已存在 | 否 |
317
+ | `UPLOAD_REJECTED` | 违反上传策略 | 否 |
318
+ | `UNSUPPORTED` | 适配器不支持该能力 | 否 |
319
+ | `TIMEOUT` | 单次尝试超时 | 是 |
320
+ | `ABORTED` | 调用方取消 | 否 |
321
+ | `ACCESS_DENIED` | 权限不足或签名校验失败 | 否 |
322
+ | `CLOSED` | 服务已关闭 | 否 |
323
+ | `BACKEND` | 后端错误;408/429/5xx 与网络瞬时错误可重试 | 视情况 |
324
+
325
+ ```ts
326
+ import { isStorageError } from '@lhy-meta-server/meta-oss'
327
+
328
+ try {
329
+ return await storage.getText(key)
330
+ } catch (error) {
331
+ if (isStorageError(error, 'NOT_FOUND')) return null
332
+ throw error
333
+ }
334
+ ```
335
+
336
+ 错误对象还包含 `operation`、`key`、`retryable`,原始异常保存在 `cause` 中。
337
+
338
+ ## 可观测性
339
+
340
+ ```ts
341
+ import { ObjectStorage, StorageMetrics } from '@lhy-meta-server/meta-oss'
342
+
343
+ const metrics = new StorageMetrics()
344
+ const storage = new ObjectStorage({
345
+ driver,
346
+ metrics,
347
+ observer: {
348
+ onOperation(event) {
349
+ logger.info(event, 'oss operation')
350
+ histogram.observe({ operation: event.operation, outcome: event.outcome }, event.duration)
351
+ },
352
+ onRetry(event) {
353
+ logger.warn({ operation: event.operation, attempt: event.attempt, delay: event.delay }, 'oss retry')
354
+ },
355
+ },
356
+ })
357
+
358
+ metrics.snapshot()
359
+ // { operations, errors, retries, inFlight, bytesWritten, totalDuration, byOperation, byErrorCode }
360
+ ```
361
+
362
+ `StorageEvent` 字段:`operation`、`outcome`、`driver`、`key`(不含前缀)、`duration`、`attempts`、`bytes`、`errorCode`、`error`、`timestamp`。观察器抛出的异常会被隔离,不影响业务请求。
363
+
364
+ 事件中的 `error` 是脱敏摘要 `StorageErrorSummary`(`code`、`message`、`retryable`、`status`、`causeCode`、`requestId`),可直接写入日志:不包含原始 SDK 错误对象,`BACKEND` 错误的消息也替换为固定文本,避免请求参数、请求头或签名随日志泄露。需要完整错误时请在业务调用处捕获 `StorageError` 并读取 `cause`。
365
+
366
+ ## 测试
367
+
368
+ ```bash
369
+ bun run test # 单元测试
370
+ bun run test:coverage # 覆盖率,函数与行均要求 100%
371
+ bun run check # 类型检查 + Lint + 覆盖率 + 构建 + Node/Bun 发布包运行验证
372
+ ```
373
+
374
+ 测试完全离线:本地存储使用临时目录,阿里云适配器使用模拟客户端,不需要真实凭据。
375
+
376
+ ## License
377
+
378
+ [MIT](./LICENSE)
@@ -0,0 +1,185 @@
1
+ import type { Readable } from 'node:stream';
2
+ /**
3
+ * ali-oss 响应中的 HTTP 信息。
4
+ */
5
+ export interface AliOssResponseInfo {
6
+ /**
7
+ * HTTP 状态码。
8
+ */
9
+ status?: number;
10
+ /**
11
+ * 响应头,键为小写。
12
+ */
13
+ headers?: Record<string, unknown>;
14
+ }
15
+ /**
16
+ * ali-oss 列举结果中的单个对象。
17
+ */
18
+ export interface AliOssListObject {
19
+ /**
20
+ * 完整对象键。
21
+ */
22
+ name: string;
23
+ /**
24
+ * 对象字节数。
25
+ */
26
+ size: number;
27
+ /**
28
+ * 带引号的 ETag。
29
+ */
30
+ etag?: string;
31
+ /**
32
+ * ISO 格式的最后修改时间。
33
+ */
34
+ lastModified?: string;
35
+ /**
36
+ * 对象类型。
37
+ */
38
+ type?: string;
39
+ }
40
+ /**
41
+ * ali-oss `list` 方法的返回值。
42
+ */
43
+ export interface AliOssListResult {
44
+ /**
45
+ * 对象列表;没有对象时可能为 `undefined`。
46
+ */
47
+ objects?: AliOssListObject[];
48
+ /**
49
+ * 公共前缀;没有时可能为 `null`。
50
+ */
51
+ prefixes?: string[] | null;
52
+ /**
53
+ * 是否还有下一页。
54
+ */
55
+ isTruncated?: boolean;
56
+ /**
57
+ * 下一页的 marker。
58
+ */
59
+ nextMarker?: string | null;
60
+ }
61
+ /**
62
+ * 适配器调用 ali-oss 时使用的请求选项子集。
63
+ */
64
+ export interface AliOssRequestOptions {
65
+ /**
66
+ * MIME 类型。
67
+ */
68
+ mime?: string;
69
+ /**
70
+ * 自定义元数据,SDK 会转换为 `x-oss-meta-*` 头部。
71
+ */
72
+ meta?: Record<string, string>;
73
+ /**
74
+ * 额外请求头。
75
+ */
76
+ headers?: Record<string, string>;
77
+ /**
78
+ * 请求超时毫秒数。
79
+ */
80
+ timeout?: number;
81
+ /**
82
+ * 是否以安静模式批量删除。
83
+ */
84
+ quiet?: boolean;
85
+ }
86
+ /**
87
+ * 适配器依赖的 ali-oss 客户端最小结构。
88
+ *
89
+ * 本包不依赖 `ali-oss`:业务自行安装并创建 `new OSS({...})` 实例后直接传入,
90
+ * 适配器只调用以下稳定方法,因此同样兼容 `OSS.Cluster` 或自定义封装。
91
+ */
92
+ export interface AliOssClient {
93
+ /**
94
+ * 上传 Buffer。
95
+ */
96
+ put(name: string, file: Buffer, options?: AliOssRequestOptions): Promise<{
97
+ res: AliOssResponseInfo;
98
+ }>;
99
+ /**
100
+ * 上传 Node.js 可读流。
101
+ */
102
+ putStream(name: string, stream: Readable, options?: AliOssRequestOptions & {
103
+ contentLength?: number;
104
+ }): Promise<{
105
+ res: AliOssResponseInfo;
106
+ }>;
107
+ /**
108
+ * 以流方式下载。
109
+ */
110
+ getStream(name: string, options?: AliOssRequestOptions): Promise<{
111
+ stream: Readable;
112
+ res: AliOssResponseInfo;
113
+ }>;
114
+ /**
115
+ * 读取对象头部。
116
+ */
117
+ head(name: string, options?: AliOssRequestOptions): Promise<{
118
+ res: AliOssResponseInfo;
119
+ status?: number;
120
+ }>;
121
+ /**
122
+ * 读取对象元信息(不受 gzip 影响的准确大小与 ETag);可选,新版 SDK 提供。
123
+ */
124
+ getObjectMeta?(name: string, options?: AliOssRequestOptions): Promise<{
125
+ res: AliOssResponseInfo;
126
+ status?: number;
127
+ }>;
128
+ /**
129
+ * 删除对象。
130
+ */
131
+ delete(name: string, options?: AliOssRequestOptions): Promise<unknown>;
132
+ /**
133
+ * 批量删除对象。
134
+ */
135
+ deleteMulti(names: string[], options?: AliOssRequestOptions): Promise<{
136
+ deleted?: Array<string | {
137
+ Key: string;
138
+ }>;
139
+ }>;
140
+ /**
141
+ * 列举对象。
142
+ */
143
+ list(query: {
144
+ prefix?: string;
145
+ delimiter?: string;
146
+ marker?: string;
147
+ 'max-keys'?: number;
148
+ }, options?: AliOssRequestOptions): Promise<AliOssListResult>;
149
+ /**
150
+ * 服务端复制对象。
151
+ */
152
+ copy(name: string, sourceName: string, options?: AliOssRequestOptions): Promise<unknown>;
153
+ /**
154
+ * 同步生成签名 URL(旧版 SDK)。
155
+ */
156
+ signatureUrl?(name: string, options?: Record<string, unknown>): string;
157
+ /**
158
+ * 异步生成签名 URL(新版 SDK,支持 STS 自动刷新)。
159
+ */
160
+ asyncSignatureUrl?(name: string, options?: Record<string, unknown>): Promise<string>;
161
+ /**
162
+ * 初始化分片上传。
163
+ */
164
+ initMultipartUpload(name: string, options?: AliOssRequestOptions): Promise<{
165
+ uploadId: string;
166
+ }>;
167
+ /**
168
+ * 上传分片,`start` 与 `end` 为 `file` 中的字节偏移(`end` 不包含在内)。
169
+ */
170
+ uploadPart(name: string, uploadId: string, partNumber: number, file: Buffer, start: number, end: number, options?: AliOssRequestOptions): Promise<{
171
+ etag: string;
172
+ }>;
173
+ /**
174
+ * 完成分片上传。
175
+ */
176
+ completeMultipartUpload(name: string, uploadId: string, parts: Array<{
177
+ number: number;
178
+ etag: string;
179
+ }>, options?: AliOssRequestOptions): Promise<unknown>;
180
+ /**
181
+ * 放弃分片上传。
182
+ */
183
+ abortMultipartUpload(name: string, uploadId: string, options?: AliOssRequestOptions): Promise<unknown>;
184
+ }
185
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/ali-oss/client.ts"],"names":[],"mappings":"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"}