@h-ai/storage 0.1.0-alpha.67 → 0.1.0-alpha.68
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/README.en.md +85 -14
- package/README.ja.md +85 -14
- package/README.md +125 -14
- package/dist/browser.d.ts +399 -93
- package/dist/browser.js +3 -2
- package/dist/{chunk-KXE6OO5T.js → chunk-C7FR45VO.js} +8 -5
- package/dist/chunk-C7FR45VO.js.map +1 -0
- package/dist/chunk-I4BSES63.js +357 -0
- package/dist/chunk-I4BSES63.js.map +1 -0
- package/dist/chunk-P65WYXJG.js +33 -0
- package/dist/chunk-P65WYXJG.js.map +1 -0
- package/dist/client/index.d.ts +57 -48
- package/dist/client/index.js +1 -1
- package/dist/node.d.ts +15 -11
- package/dist/node.js +296 -136
- package/dist/node.js.map +1 -1
- package/dist/schemas.d.ts +103 -0
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -0
- package/package.json +7 -2
- package/dist/chunk-25GYTRA7.js +0 -274
- package/dist/chunk-25GYTRA7.js.map +0 -1
- package/dist/chunk-KXE6OO5T.js.map +0 -1
package/README.en.md
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
An object storage module with a unified `storage` entry point for S3-compatible storage and local filesystems.
|
|
10
10
|
|
|
11
|
+
Connections become available only after a successful health check. Failed initialization disposes its candidate; `error.ext.cleanupPending === true` identifies cleanup that still needs retry. `storage.close()` returns `Promise<HaiResult<void>>`, waits for initialization, and coalesces concurrent close calls. Once cleanup starts, `isInitialized` is false and configuration/operations are unavailable. A failed cleanup retains the resource: the next `close()` or `init()` retries disposal before allocating a replacement. Always inspect the close result.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const closed = await storage.close()
|
|
15
|
+
if (!closed.success) {
|
|
16
|
+
// Retry once after addressing the cleanup failure; report another failure.
|
|
17
|
+
const retried = await storage.close()
|
|
18
|
+
if (!retried.success)
|
|
19
|
+
throw new Error(retried.error.message)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
S3 disposes its normal client and its separate batch-delete client (SDK request retries disabled) independently. One failure does not prevent the other cleanup; successfully disposed clients are not destroyed again.
|
|
24
|
+
|
|
11
25
|
## Core features
|
|
12
26
|
|
|
13
27
|
- S3 protocol (AWS S3 / MinIO / RustFS / OSS, etc.)
|
|
@@ -40,6 +54,10 @@ const file = await storage.file.get('uploads/image.png')
|
|
|
40
54
|
|
|
41
55
|
// 签名 URL
|
|
42
56
|
const url = await storage.presign.getUrl('uploads/image.png', { expiresIn: 3600 })
|
|
57
|
+
if (!url.success) {
|
|
58
|
+
// local 不支持预签名;呈现错误或使用应用已有的鉴权文件接口。
|
|
59
|
+
// 失败结果不包含上传/下载 URL。
|
|
60
|
+
}
|
|
43
61
|
|
|
44
62
|
// 关闭连接
|
|
45
63
|
const closeResult = await storage.close()
|
|
@@ -53,25 +71,32 @@ if (!closeResult.success) {
|
|
|
53
71
|
The browser entry exports only client capabilities (signed URL uploads/downloads), without the `storage` service object.
|
|
54
72
|
|
|
55
73
|
```ts
|
|
74
|
+
import { apiClient } from '@h-ai/api-client'
|
|
56
75
|
import { downloadAndSave, uploadWithPresignedUrl } from '@h-ai/storage/client'
|
|
57
76
|
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
77
|
+
// apiClient 已初始化,服务端使用 S3。
|
|
78
|
+
const signed = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
79
|
+
if (!signed.success)
|
|
80
|
+
throw new Error(signed.error.message)
|
|
81
|
+
await uploadWithPresignedUrl(signed.data.url, file)
|
|
61
82
|
```
|
|
62
83
|
|
|
63
84
|
> Note: `@h-ai/storage` does not include HTTP routes. Implement the signed URL API in the application layer.
|
|
64
85
|
|
|
65
86
|
### HTTP API contracts
|
|
66
87
|
|
|
67
|
-
Public HTTP contracts come from `@h-ai/api-contract`; `createStorageProcedures({ storage })` in `@h-ai/serv` binds them to this module.
|
|
88
|
+
Public HTTP contracts come from `@h-ai/api-contract`; `createStorageProcedures({ storage, authorize })` in `@h-ai/serv` binds them to this module.
|
|
68
89
|
|
|
69
90
|
```ts
|
|
70
91
|
import { apiClient } from '@h-ai/api-client'
|
|
71
92
|
|
|
72
93
|
const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
94
|
+
if (!result.success)
|
|
95
|
+
throw new Error(result.error.message)
|
|
73
96
|
```
|
|
74
97
|
|
|
98
|
+
Presigning requires S3. Local `getUrl()` / `putUrl()` return `HaiStorageError.UNSUPPORTED_OPERATION` (`hai:storage:018`), and `publicUrl()` returns `null`; no virtual URL is returned. serv and apiClient preserve the failure, so check `success` before consuming a URL. Use the application's existing authenticated file endpoints for local development; this module includes no HTTP file route or signature-verification service.
|
|
99
|
+
|
|
75
100
|
### API overview
|
|
76
101
|
|
|
77
102
|
- `storage.file`
|
|
@@ -87,6 +112,21 @@ const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar
|
|
|
87
112
|
|
|
88
113
|
`{ ifNoneMatch: '*' }` uses standard S3 conditional writes or atomic local publication to reject overwrites with `ALREADY_EXISTS`. Unsupported S3 conditions fail without falling back to an overwrite. Failed/cancelled uploads clean temporary files or unfinished parts; cancellation cannot undo an already published object.
|
|
89
114
|
|
|
115
|
+
`file.deleteMany(keys)` returns `StorageDeleteResult`. Success data is `{ deletedKeys, failures: [] }`; any unconfirmed deletion returns `OPERATION_FAILED` with progress in `error.ext?.deleteMany` (precondition errors such as uninitialized storage may omit progress). Duplicate inputs are processed once, and returned keys exclude the configured prefix. S3 batches contain at most 1000 keys and verify every `Deleted`/`Errors` receipt. HTTP 200 alone is insufficient; missing, duplicate or unknown receipts produce `unknown` outcomes. Local deletion uses at most 32 concurrent operations.
|
|
116
|
+
|
|
117
|
+
Only explicit `InternalError / ServiceUnavailable / SlowDown / RequestTimeout` items receive up to three attempts. Confirmed successes and permission failures are not resent. A dedicated `DeleteObjects` client disables SDK request retries. Network interruption immediately produces `unknown`; later unsent batches are `notAttempted`, without replaying unknown outcomes. `retryable` describes the fault category; callers must reconcile unknown results and concurrent writes before retrying. `dir.delete()` propagates failures but cannot prevent concurrent new objects. S3 deletes omit VersionId and may create delete markers in versioned buckets; historical versions are not purged.
|
|
118
|
+
|
|
119
|
+
`@h-ai/serv` / `@h-ai/api-client` expose the same success data and failure progress through `storage.files.deleteMany`.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const result = await storage.file.deleteMany(['a.txt', 'b.txt'])
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
const progress = result.error.ext?.deleteMany
|
|
125
|
+
// Reconcile unknown outcomes with application writes before compensating.
|
|
126
|
+
const knownFailures = progress?.failures.filter(item => item.outcome === 'failed' && item.retryable) ?? []
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
90
130
|
`checksum` is a stateless Node tool requiring no initialization. It generates/verifies the actual input bytes and consumes streams once. Defaults: SHA-256, lowercase hex. Options: `algorithm: 'sha256' | 'sha384' | 'sha512' | 'sha1' | 'md5'`, `encoding: 'hex' | 'base64'`, and `signal`. Mismatches return `ok(false)`; invalid format/algorithm and read failures return errors. MD5/SHA-1 are for compatibility integrity checks, not security authentication. ETags and S3 multipart composite checksums are not whole-file digests. The tool does not require compatible services to implement AWS checksum extensions.
|
|
91
131
|
|
|
92
132
|
Cancelling a checksum stops waiting for the next chunk and requests closure of the input iterator. Local `getStream()` rejects directories with `INVALID_PATH` before returning a stream.
|
|
@@ -158,7 +198,20 @@ await storage.init({
|
|
|
158
198
|
|
|
159
199
|
`storage.presign.putUrl()` currently supports signing options such as `contentType` and `expiresIn`, but no `maxSize` constraint.
|
|
160
200
|
|
|
161
|
-
|
|
201
|
+
Local `storage.dir.list()` treats `prefix` as a key string prefix (for example, `reports/2026-`) and paginates in UTF-8 byte order. `maxKeys` is an integer from 1 to 1000, default 1000; files and common prefixes share that budget. `delimiter` folds keys at its first occurrence after the prefix into one `commonPrefixes` entry. An empty delimiter disables grouping; empty physical directories produce no entries. The grouping and counting rules follow [S3 ListObjectsV2](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html).
|
|
202
|
+
|
|
203
|
+
`isTruncated: true` and `nextContinuationToken` appear only when another entry exists. Local cursors use no process cache and bind the canonical root directory, prefix and delimiter. Independent instances or restarted processes using the same root can continue a page, with a different `maxKeys` if needed. Malformed, unsupported-version or mismatched-scope cursors return `INVALID_LIST_OPTIONS`. Cursors grant no authorization and provide no snapshot: authorize the prefix, expect concurrent changes to affect later pages, and use the same canonical mount path. Each local request still scans and sorts matching directories; narrow the prefix or use S3 for large directories.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
let continuationToken: string | undefined
|
|
207
|
+
do {
|
|
208
|
+
const page = await storage.dir.list({ prefix: 'reports/2026-', maxKeys: 100, continuationToken })
|
|
209
|
+
if (!page.success)
|
|
210
|
+
return page
|
|
211
|
+
// Consume page.data.files and page.data.commonPrefixes.
|
|
212
|
+
continuationToken = page.data.nextContinuationToken
|
|
213
|
+
} while (continuationToken)
|
|
214
|
+
```
|
|
162
215
|
|
|
163
216
|
### Error handling
|
|
164
217
|
|
|
@@ -173,15 +226,29 @@ if (!result.success && result.error.code === HaiStorageError.NOT_INITIALIZED.cod
|
|
|
173
226
|
|
|
174
227
|
Common error codes:
|
|
175
228
|
|
|
176
|
-
| Error code
|
|
177
|
-
|
|
|
178
|
-
| `HaiStorageError.NOT_INITIALIZED`
|
|
179
|
-
| `HaiStorageError.CONNECTION_FAILED`
|
|
180
|
-
| `HaiStorageError.OPERATION_FAILED`
|
|
181
|
-
| `HaiStorageError.NOT_FOUND`
|
|
182
|
-
| `HaiStorageError.PERMISSION_DENIED`
|
|
183
|
-
| `HaiStorageError.PRESIGN_FAILED`
|
|
184
|
-
| `HaiStorageError.
|
|
229
|
+
| Error code | code | Description |
|
|
230
|
+
| --------------------------------------- | ----------------- | ---------------------------------------------------------------------- |
|
|
231
|
+
| `HaiStorageError.NOT_INITIALIZED` | `hai:storage:010` | Not initialized |
|
|
232
|
+
| `HaiStorageError.CONNECTION_FAILED` | `hai:storage:001` | Connection failed |
|
|
233
|
+
| `HaiStorageError.OPERATION_FAILED` | `hai:storage:002` | Operation failed |
|
|
234
|
+
| `HaiStorageError.NOT_FOUND` | `hai:storage:003` | File not found |
|
|
235
|
+
| `HaiStorageError.PERMISSION_DENIED` | `hai:storage:005` | Permission denied |
|
|
236
|
+
| `HaiStorageError.PRESIGN_FAILED` | `hai:storage:013` | Presigned URL generation failed |
|
|
237
|
+
| `HaiStorageError.UNSUPPORTED_OPERATION` | `hai:storage:018` | Operation unsupported by the provider, including local presigning |
|
|
238
|
+
| `HaiStorageError.INVALID_LIST_OPTIONS` | `hai:storage:019` | Invalid local list options or cursor, including mismatched query scope |
|
|
239
|
+
| `HaiStorageError.CONFIG_ERROR` | `hai:storage:012` | Invalid configuration |
|
|
240
|
+
|
|
241
|
+
## Production shared state
|
|
242
|
+
|
|
243
|
+
Production requires shared S3 or a compatible service; local is for development/explicit tests. Applications own object authorization and conditional read-modify-write commits. Initialization checks apply when NODE_ENV or HAI_CORE_ENV is production; only explicit HAI_E2E=1/HAI_E2E_MOCK=1 tests are exempt. [State, concurrency and recovery (Chinese)](../../.agents/instructions/distributed-state.md)。
|
|
244
|
+
|
|
245
|
+
## Input limits and HTTP capabilities
|
|
246
|
+
|
|
247
|
+
Object keys are limited to 1024 UTF-8 bytes and reject absolute paths, backslashes, control characters and dot segments. Each public HTTP batch delete accepts 1–1000 entries. `@h-ai/storage/schemas` exposes the shared browser-safe rules. Presign expiry is an integer from 1 to 604800 seconds (Node default 3600, HTTP default 900). Upload signing accepts only key/contentType/expiresIn; contentLength/maxSize/metadata/checksum are unsupported. Invalid input returns HTTP 400 and the typed client maps it to VALIDATION_FAILED. FileUpload limits guide file selection; applications must validate actual objects on the server. Node streaming/checksum/conditional-write options have no default HTTP endpoints. See the [capability and authorization matrix (Chinese)](README.md#存储输入约束与跨端能力).
|
|
248
|
+
|
|
249
|
+
## Browser upload cancellation
|
|
250
|
+
|
|
251
|
+
`uploadWithPresignedUrl(url, data, { onProgress, abortController })` uses native XHR for progress and fetch when `onProgress` is omitted. An already aborted controller sends no request. Cancellation during setup or upload, external XHR abort, network/timeout errors, and progress callback exceptions settle once with a failure and remove this operation's listeners. Aborting after completion does not touch the old XHR. Cancellation only stops the local request; it cannot roll back an object already written remotely. Check the object state and authorize deletion separately.
|
|
185
252
|
|
|
186
253
|
## Development and testing
|
|
187
254
|
|
|
@@ -196,6 +263,10 @@ S3 integration tests require Docker or a compatible runtime. The same initializa
|
|
|
196
263
|
|
|
197
264
|
Containers use temporary test credentials. Buckets are created through the S3 API after service readiness; containers and anonymous volumes are removed when the final lease is released.
|
|
198
265
|
|
|
266
|
+
Source JSDoc follows the [shared conventions](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证): public examples use published entry points, and internal examples are checked in their module scope. After building, run `pnpm check:jsdoc --package storage`; compiling examples does not replace backend behavior tests.
|
|
267
|
+
|
|
268
|
+
For the real Chrome regression gate, install Chrome, set `HAI_STORAGE_RUN_BROWSER_GATES=1`, then run `pnpm --filter @h-ai/storage exec vitest run tests/storage-client-browser.test.ts`. It sends actual HTTP PUT requests and checks native cancellation and listener cleanup; a missing browser fails the gate.
|
|
269
|
+
|
|
199
270
|
## License
|
|
200
271
|
|
|
201
272
|
Apache-2.0
|
package/README.ja.md
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
共通の `storage` 入口で S3 互換ストレージとローカルファイルシステムを操作するオブジェクトストレージモジュールです。
|
|
10
10
|
|
|
11
|
+
接続はヘルスチェック成功後に公開します。初期化失敗時は候補リソースを解放し、解放にも失敗した場合は初期化エラーの `error.ext.cleanupPending` が `true` になります。`storage.close()` は `Promise<HaiResult<void>>` を返し、初期化を待機して並行する終了処理をまとめます。解放開始後は `isInitialized` が `false` となり、設定と操作は利用できません。解放に失敗したリソースは保持され、次の `close()` または `init()` で再試行します。解放が成功するまで代替接続は作成しません。終了結果を必ず確認してください。
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const closed = await storage.close()
|
|
15
|
+
if (!closed.success) {
|
|
16
|
+
// 障害への対処後に一度再試行し、再失敗はアプリへ報告します。
|
|
17
|
+
const retried = await storage.close()
|
|
18
|
+
if (!retried.success)
|
|
19
|
+
throw new Error(retried.error.message)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
S3 の通常クライアントと、リクエスト再試行を無効化した一括削除クライアントは個別に解放します。一方の失敗でも他方は解放し、解放済みクライアントは再度破棄しません。
|
|
24
|
+
|
|
11
25
|
## 主な機能
|
|
12
26
|
|
|
13
27
|
- S3 プロトコル(AWS S3 / MinIO / RustFS / OSS など)
|
|
@@ -40,6 +54,10 @@ const file = await storage.file.get('uploads/image.png')
|
|
|
40
54
|
|
|
41
55
|
// 签名 URL
|
|
42
56
|
const url = await storage.presign.getUrl('uploads/image.png', { expiresIn: 3600 })
|
|
57
|
+
if (!url.success) {
|
|
58
|
+
// local 不支持预签名;呈现错误或使用应用已有的鉴权文件接口。
|
|
59
|
+
// 失败结果不包含上传/下载 URL。
|
|
60
|
+
}
|
|
43
61
|
|
|
44
62
|
// 关闭连接
|
|
45
63
|
const closeResult = await storage.close()
|
|
@@ -53,25 +71,32 @@ if (!closeResult.success) {
|
|
|
53
71
|
ブラウザー環境ではクライアント機能(署名付き URL のアップロード・ダウンロード)のみをエクスポートし、`storage` サービスオブジェクトは含みません。
|
|
54
72
|
|
|
55
73
|
```ts
|
|
74
|
+
import { apiClient } from '@h-ai/api-client'
|
|
56
75
|
import { downloadAndSave, uploadWithPresignedUrl } from '@h-ai/storage/client'
|
|
57
76
|
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
77
|
+
// apiClient 已初始化,服务端使用 S3。
|
|
78
|
+
const signed = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
79
|
+
if (!signed.success)
|
|
80
|
+
throw new Error(signed.error.message)
|
|
81
|
+
await uploadWithPresignedUrl(signed.data.url, file)
|
|
61
82
|
```
|
|
62
83
|
|
|
63
84
|
> 注:`@h-ai/storage` に HTTP ルートは組み込まれていません。署名付き URL API はアプリケーション層で実装してください。
|
|
64
85
|
|
|
65
86
|
### HTTP API 契約
|
|
66
87
|
|
|
67
|
-
公開 HTTP API は `@h-ai/api-contract` で定義し、`@h-ai/serv` の `createStorageProcedures({ storage })` で本モジュールの機能に接続します。
|
|
88
|
+
公開 HTTP API は `@h-ai/api-contract` で定義し、`@h-ai/serv` の `createStorageProcedures({ storage, authorize })` で本モジュールの機能に接続します。
|
|
68
89
|
|
|
69
90
|
```ts
|
|
70
91
|
import { apiClient } from '@h-ai/api-client'
|
|
71
92
|
|
|
72
93
|
const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
94
|
+
if (!result.success)
|
|
95
|
+
throw new Error(result.error.message)
|
|
73
96
|
```
|
|
74
97
|
|
|
98
|
+
署名付き URL は S3 のみ対応します。local の `getUrl()` / `putUrl()` は `HaiStorageError.UNSUPPORTED_OPERATION`(`hai:storage:018`)、`publicUrl()` は `null` を返し、仮想 URL は返しません。serv と apiClient は失敗結果を保持するため、URL を使う前に `success` を確認してください。ローカル開発ではアプリの既存の認証付きファイル API を利用します。このモジュールに HTTP ファイルルートや署名検証サービスは含まれません。
|
|
99
|
+
|
|
75
100
|
### API 一覧
|
|
76
101
|
|
|
77
102
|
- `storage.file`
|
|
@@ -87,6 +112,21 @@ const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar
|
|
|
87
112
|
|
|
88
113
|
`{ ifNoneMatch: '*' }` は標準 S3 条件付き書き込み、またはローカルの原子的公開で上書きを拒否し、競合時に `ALREADY_EXISTS` を返します。非対応の S3 サービスではエラーとし、上書きに切り替えません。失敗・中止時は一時ファイルや未完了パートを削除しますが、公開済みオブジェクトは中止で取り消せません。
|
|
89
114
|
|
|
115
|
+
`file.deleteMany(keys)` は `StorageDeleteResult` を返します。成功時の data は `{ deletedKeys, failures: [] }`、削除未完了の項目がある場合は `OPERATION_FAILED` と `error.ext?.deleteMany` に進捗を返します(未初期化などの事前エラーには進捗がない場合があります)。重複入力は一度だけ処理し、返す key に設定 prefix を含めません。S3 は最大 1000 件ずつ送信し、すべての `Deleted` / `Errors` を照合します。HTTP 200 でも全件成功とは限らず、欠落・重複・不明な応答は `unknown` になります。ローカル削除の並行数は最大 32 件です。
|
|
116
|
+
|
|
117
|
+
明示的な `InternalError / ServiceUnavailable / SlowDown / RequestTimeout` の項目だけ最大 3 回試行し、成功済み・権限エラーは再送しません。`DeleteObjects` 専用クライアントは SDK のリクエスト再試行を無効化します。通信が中断した項目は直ちに `unknown`、後続の未送信バッチは `notAttempted` とし、不明な結果を再送しません。`retryable` は障害の分類であり、再試行前に不明な結果と並行書き込みを確認してください。`dir.delete()` は削除エラーを伝播しますが、並行して追加されるオブジェクトは防げません。S3 の削除は VersionId を指定しないため、バージョニングが有効な場合は削除マーカーを作成することがあり、過去のバージョンは消去しません。
|
|
118
|
+
|
|
119
|
+
`@h-ai/serv` / `@h-ai/api-client` の `storage.files.deleteMany` も同じ成功データと失敗進捗を返します。
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const result = await storage.file.deleteMany(['a.txt', 'b.txt'])
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
const progress = result.error.ext?.deleteMany
|
|
125
|
+
// 不明な結果と業務上の書き込みを確認してから補償する。
|
|
126
|
+
const knownFailures = progress?.failures.filter(item => item.outcome === 'failed' && item.retryable) ?? []
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
90
130
|
`checksum` は初期化不要の Node 専用ツールです。実際の入力バイトを生成・検証し、ストリームは一度だけ消費します。既定値は SHA-256、小文字 hex。`algorithm: 'sha256' | 'sha384' | 'sha512' | 'sha1' | 'md5'`、`encoding: 'hex' | 'base64'`、`signal` を指定できます。不一致は `ok(false)`、不正形式・アルゴリズムや読み取り失敗はエラーです。MD5/SHA-1 は互換性確認のみで、認証用ではありません。ETag と multipart の複合 checksum はファイル全体の摘要と異なり、AWS checksum 拡張の実装は強制しません。
|
|
91
131
|
|
|
92
132
|
checksum の中止時は次のチャンクを待つ処理を終了し、入力イテレーターの終了を要求します。ローカルの `getStream()` はディレクトリをストリームとして返さず、その場で `INVALID_PATH` を返します。
|
|
@@ -158,7 +198,20 @@ await storage.init({
|
|
|
158
198
|
|
|
159
199
|
`storage.presign.putUrl()` は現在 `contentType` や `expiresIn` などの署名パラメーターに対応しますが、`maxSize` 制限は提供しません。
|
|
160
200
|
|
|
161
|
-
ローカルの `storage.dir.list()` は prefix
|
|
201
|
+
ローカルの `storage.dir.list()` は `prefix` を key の文字列接頭辞(例:`reports/2026-`)として扱い、UTF-8 バイト順でページ分割します。`maxKeys` は 1–1000 の整数で既定値は 1000、`files` と `commonPrefixes` の合計件数に適用します。`delimiter` は prefix の後で最初に現れる区切りまでを一つの `commonPrefixes` 項目にまとめます。空文字列ではグループ化せず、空の物理ディレクトリーは項目になりません。グループ化と件数の規則は [S3 ListObjectsV2](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) に基づきます。
|
|
202
|
+
|
|
203
|
+
後続項目がある場合にだけ `isTruncated: true` と `nextContinuationToken` を返します。ローカルカーソルはプロセス内キャッシュを使わず、正規化したルートディレクトリー、prefix、delimiter に結び付きます。同じルートを使用する別インスタンスや再起動後も継続でき、次ページの `maxKeys` は変更可能です。不正な形式、未対応バージョン、範囲が異なるカーソルは `INVALID_LIST_OPTIONS` になります。カーソルは認可情報でもスナップショットでもありません。prefix の認可は引き続き必要で、同時更新は後続ページに影響し、異なるマウントパスは同じ範囲として扱いません。各リクエストで対象ディレクトリーの走査と整列を行うため、大きな範囲では prefix を絞るか S3 を使用してください。
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
let continuationToken: string | undefined
|
|
207
|
+
do {
|
|
208
|
+
const page = await storage.dir.list({ prefix: 'reports/2026-', maxKeys: 100, continuationToken })
|
|
209
|
+
if (!page.success)
|
|
210
|
+
return page
|
|
211
|
+
// page.data.files と page.data.commonPrefixes を処理。
|
|
212
|
+
continuationToken = page.data.nextContinuationToken
|
|
213
|
+
} while (continuationToken)
|
|
214
|
+
```
|
|
162
215
|
|
|
163
216
|
### エラー処理
|
|
164
217
|
|
|
@@ -173,15 +226,29 @@ if (!result.success && result.error.code === HaiStorageError.NOT_INITIALIZED.cod
|
|
|
173
226
|
|
|
174
227
|
主なエラーコード:
|
|
175
228
|
|
|
176
|
-
| エラーコード
|
|
177
|
-
|
|
|
178
|
-
| `HaiStorageError.NOT_INITIALIZED`
|
|
179
|
-
| `HaiStorageError.CONNECTION_FAILED`
|
|
180
|
-
| `HaiStorageError.OPERATION_FAILED`
|
|
181
|
-
| `HaiStorageError.NOT_FOUND`
|
|
182
|
-
| `HaiStorageError.PERMISSION_DENIED`
|
|
183
|
-
| `HaiStorageError.PRESIGN_FAILED`
|
|
184
|
-
| `HaiStorageError.
|
|
229
|
+
| エラーコード | code | 説明 |
|
|
230
|
+
| --------------------------------------- | ----------------- | ------------------------------------------------------------ |
|
|
231
|
+
| `HaiStorageError.NOT_INITIALIZED` | `hai:storage:010` | 未初期化 |
|
|
232
|
+
| `HaiStorageError.CONNECTION_FAILED` | `hai:storage:001` | 接続に失敗 |
|
|
233
|
+
| `HaiStorageError.OPERATION_FAILED` | `hai:storage:002` | 操作に失敗 |
|
|
234
|
+
| `HaiStorageError.NOT_FOUND` | `hai:storage:003` | ファイルが存在しない |
|
|
235
|
+
| `HaiStorageError.PERMISSION_DENIED` | `hai:storage:005` | 権限不足 |
|
|
236
|
+
| `HaiStorageError.PRESIGN_FAILED` | `hai:storage:013` | 署名付き URL の生成に失敗 |
|
|
237
|
+
| `HaiStorageError.UNSUPPORTED_OPERATION` | `hai:storage:018` | Provider が操作をサポートしない(local の署名付き URL など) |
|
|
238
|
+
| `HaiStorageError.INVALID_LIST_OPTIONS` | `hai:storage:019` | ローカル一覧の引数・カーソルが不正、または検索範囲が不一致 |
|
|
239
|
+
| `HaiStorageError.CONFIG_ERROR` | `hai:storage:012` | 設定エラー |
|
|
240
|
+
|
|
241
|
+
## 本番の共有状態
|
|
242
|
+
|
|
243
|
+
本番は共有 S3 または互換サービスを使います。local は開発・明示的テスト用です。オブジェクト認可と読取後更新の条件付きコミットはアプリが担当します。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
|
|
244
|
+
|
|
245
|
+
## 入力制約と HTTP 機能
|
|
246
|
+
|
|
247
|
+
キーは UTF-8 で最大 1024 バイトで、絶対パス、バックスラッシュ、制御文字、ドットセグメントを拒否します。公開 HTTP 一括削除は一度に 1–1000 件です。共通規則はブラウザー対応の `@h-ai/storage/schemas` から参照できます。署名有効期限は整数 1–604800 秒(Node 既定 3600、HTTP 既定 900)。アップロード署名の入力は key/contentType/expiresIn のみで、contentLength/maxSize/metadata/checksum は未対応です。不正入力は HTTP 400、typed client では VALIDATION_FAILED です。FileUpload の制限は選択時の補助であり、実際のオブジェクトはサーバーで検証してください。Node のストリーム/checksum/条件付き書き込みには既定の HTTP エンドポイントがありません。[機能と認可の対応表(中国語)](README.md#存储输入约束与跨端能力)を参照してください。
|
|
248
|
+
|
|
249
|
+
## ブラウザーアップロードのキャンセル
|
|
250
|
+
|
|
251
|
+
`uploadWithPresignedUrl(url, data, { onProgress, abortController })` は進捗通知にネイティブXHRを使用し、`onProgress` を省略するとfetchを使用します。事前にキャンセルされた場合は送信しません。準備中・送信中のキャンセル、外部からのXHR abort、通信・タイムアウト、進捗コールバックの例外は一度だけ失敗として確定し、この操作のリスナーを解除します。完了後のキャンセルは古いXHRを操作しません。キャンセルはローカル要求の停止であり、リモートに保存済みのオブジェクトを取り消せません。状態を確認してから、別途認可して削除してください。
|
|
185
252
|
|
|
186
253
|
## 開発とテスト
|
|
187
254
|
|
|
@@ -196,6 +263,10 @@ S3 統合テストには Docker または互換ランタイムが必要です。
|
|
|
196
263
|
|
|
197
264
|
コンテナーは一時生成したテスト認証情報を使用します。サービスの準備完了後に S3 API でバケットを作成し、最後のリース解放時にコンテナーと匿名ボリュームを削除します。
|
|
198
265
|
|
|
266
|
+
ソースの JSDoc は[共通規約](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)に従います。公開例は実際の公開入口、内部例は元のモジュールスコープで型検査します。ビルド後に `pnpm check:jsdoc --package storage` を実行してください。例の型検査は実バックエンドの動作検証の代わりにはなりません。
|
|
267
|
+
|
|
268
|
+
実Chromeの回帰ゲートはChromeを用意し、`HAI_STORAGE_RUN_BROWSER_GATES=1` を設定して `pnpm --filter @h-ai/storage exec vitest run tests/storage-client-browser.test.ts` を実行します。実際のHTTP PUTでネイティブキャンセルとリスナー解放を検証し、ブラウザーがなければ失敗します。
|
|
269
|
+
|
|
199
270
|
## ライセンス
|
|
200
271
|
|
|
201
272
|
Apache-2.0
|
package/README.md
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
对象存储模块,提供统一 `storage` 访问入口,支持 S3 兼容存储与本地文件系统。
|
|
10
10
|
|
|
11
|
+
初始化只在健康检查成功后发布连接;失败时释放候选资源。若清理仍失败,初始化错误的 `error.ext.cleanupPending` 为 `true`。`storage.close()` 返回 `Promise<HaiResult<void>>`,等待正在进行的初始化,并合并并发关闭。开始清理后 `isInitialized` 为 `false`,配置与业务操作不再可用;清理失败仍保留资源,后续 `close()` 或 `init()` 先重试释放,成功前不会创建替代连接。调用方必须检查关闭结果。
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const closed = await storage.close()
|
|
15
|
+
if (!closed.success) {
|
|
16
|
+
// 处理清理故障后有限重试;再次失败应向应用报告。
|
|
17
|
+
const retried = await storage.close()
|
|
18
|
+
if (!retried.success)
|
|
19
|
+
throw new Error(retried.error.message)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
S3 普通请求客户端与禁用请求重试的批量删除客户端分别释放;一个失败不阻止另一个清理,已释放的客户端不会重复释放。
|
|
24
|
+
|
|
11
25
|
## 核心功能清单
|
|
12
26
|
|
|
13
27
|
- S3 协议(AWS S3 / MinIO / RustFS / OSS 等)
|
|
@@ -40,6 +54,10 @@ const file = await storage.file.get('uploads/image.png')
|
|
|
40
54
|
|
|
41
55
|
// 签名 URL
|
|
42
56
|
const url = await storage.presign.getUrl('uploads/image.png', { expiresIn: 3600 })
|
|
57
|
+
if (!url.success) {
|
|
58
|
+
// local 不支持预签名;向用户呈现错误,或使用应用已有的鉴权文件接口。
|
|
59
|
+
// 失败结果不含可上传/下载的 URL。
|
|
60
|
+
}
|
|
43
61
|
|
|
44
62
|
// 关闭连接
|
|
45
63
|
const closeResult = await storage.close()
|
|
@@ -53,25 +71,32 @@ if (!closeResult.success) {
|
|
|
53
71
|
浏览器环境下仅导出客户端能力(签名 URL 上传/下载),不包含 `storage` 服务对象。
|
|
54
72
|
|
|
55
73
|
```ts
|
|
74
|
+
import { apiClient } from '@h-ai/api-client'
|
|
56
75
|
import { downloadAndSave, uploadWithPresignedUrl } from '@h-ai/storage/client'
|
|
57
76
|
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
77
|
+
// apiClient 已初始化,服务端使用 S3。
|
|
78
|
+
const signed = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
79
|
+
if (!signed.success)
|
|
80
|
+
throw new Error(signed.error.message)
|
|
81
|
+
await uploadWithPresignedUrl(signed.data.url, file)
|
|
61
82
|
```
|
|
62
83
|
|
|
63
84
|
> 注意:`@h-ai/storage` 不内置 HTTP 路由;请在应用层自行实现签名 URL API。
|
|
64
85
|
|
|
65
86
|
### HTTP API 契约
|
|
66
87
|
|
|
67
|
-
公共 HTTP API 统一由 `@h-ai/api-contract` 提供,并由 `@h-ai/serv` 的 `createStorageProcedures({ storage })` 绑定到本模块能力。
|
|
88
|
+
公共 HTTP API 统一由 `@h-ai/api-contract` 提供,并由 `@h-ai/serv` 的 `createStorageProcedures({ storage, authorize })` 绑定到本模块能力。
|
|
68
89
|
|
|
69
90
|
```ts
|
|
70
91
|
import { apiClient } from '@h-ai/api-client'
|
|
71
92
|
|
|
72
93
|
const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar.png' })
|
|
94
|
+
if (!result.success)
|
|
95
|
+
throw new Error(result.error.message)
|
|
73
96
|
```
|
|
74
97
|
|
|
98
|
+
预签名只支持 S3。local 的 `getUrl()` / `putUrl()` 返回 `HaiStorageError.UNSUPPORTED_OPERATION`(`hai:storage:018`),`publicUrl()` 返回 `null`;不会返回虚拟 URL。serv 与 apiClient 保留该失败结果,调用方须先检查 `success` 再消费 URL。本地开发文件可由应用已有的鉴权上传/下载接口处理,本模块不内置 HTTP 文件路由或签名验证服务。
|
|
99
|
+
|
|
75
100
|
### API 概览
|
|
76
101
|
|
|
77
102
|
- `storage.file`
|
|
@@ -87,6 +112,21 @@ const result = await apiClient.storage.presignedUrls.createUpload({ key: 'avatar
|
|
|
87
112
|
|
|
88
113
|
上传选项 `{ ifNoneMatch: '*' }` 通过标准 S3 条件写或本地原子发布拒绝覆盖,冲突返回 `ALREADY_EXISTS`;S3 服务不支持条件写时返回错误,不降级为覆盖。失败/取消会清理临时文件或未完成分片;取消不能撤销已经发布的对象。
|
|
89
114
|
|
|
115
|
+
`file.deleteMany(keys)` 返回 `StorageDeleteResult`:成功时 `data` 为 `{ deletedKeys, failures: [] }`;任一删除项未确认完成时返回 `OPERATION_FAILED`,进度在 `error.ext?.deleteMany`(初始化等前置错误可能不带进度)。重复输入只处理一次,输出 key 不含配置 prefix。S3 每批最多 1000 项,逐项检查 `Deleted` 与 `Errors`;HTTP 200 不代表全部成功,遗漏/重复/未知回执标为 `unknown`。本地删除最多并发 32 项。
|
|
116
|
+
|
|
117
|
+
只对明确的 `InternalError / ServiceUnavailable / SlowDown / RequestTimeout` 项最多尝试三次;成功及权限失败项不重发。`DeleteObjects` 专用客户端禁用 SDK 请求重试;网络中断立即标为 `unknown`,未发送的后续批次标为 `notAttempted`,不盲目重放未知结果。`retryable` 只表示故障类别,调用方重试前仍须核对未知结果及并发写入。`dir.delete()` 传播删除失败;它不能阻止并发新增对象。S3 使用不带 VersionId 的删除,开启版本控制的桶可能创建删除标记,不保证清除历史版本。
|
|
118
|
+
|
|
119
|
+
`@h-ai/serv` / `@h-ai/api-client` 的 `storage.files.deleteMany` 使用同一成功结构并透传失败进度。
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const result = await storage.file.deleteMany(['a.txt', 'b.txt'])
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
const progress = result.error.ext?.deleteMany
|
|
125
|
+
// 结合 outcome 与业务写入状态决定补偿;未知结果不能当作未执行。
|
|
126
|
+
const knownFailures = progress?.failures.filter(item => item.outcome === 'failed' && item.retryable) ?? []
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
90
130
|
`checksum` 是 Node 端无状态工具,无需初始化,按实际输入字节生成/校验,流输入仅消费一次。默认 SHA-256、小写 hex;可选 `algorithm: 'sha256' | 'sha384' | 'sha512' | 'sha1' | 'md5'`、`encoding: 'hex' | 'base64'` 和 `signal`。不匹配返回 `ok(false)`,非法格式/算法及读取失败返回错误。MD5/SHA-1 只用于兼容性完整性校验,不用于安全认证;ETag、S3 multipart 复合 checksum 不等同于完整文件摘要。此工具不强制 S3 兼容服务支持 AWS checksum 扩展。
|
|
91
131
|
|
|
92
132
|
取消计算会结束对下一块数据的等待,并请求关闭输入迭代器。本地 `getStream()` 读取目录时直接返回 `INVALID_PATH`,不将目录报告为可消费的文件流。
|
|
@@ -158,7 +198,20 @@ await storage.init({
|
|
|
158
198
|
|
|
159
199
|
`storage.presign.putUrl()` 当前仅支持 `contentType` 与 `expiresIn` 等签名参数,不提供 `maxSize` 约束。
|
|
160
200
|
|
|
161
|
-
|
|
201
|
+
本地 `storage.dir.list()` 将 `prefix` 作为 key 字符串前缀(如 `reports/2026-`),按 UTF-8 字节序分页。`maxKeys` 为 1–1000 的整数,默认 1000,`files` 与 `commonPrefixes` 共用该预算。`delimiter` 将前缀之后首次出现的分隔符及其后内容折叠为一个 `commonPrefixes` 条目;空字符串不折叠,空物理目录不生成条目。这些分组与计数规则参照 [S3 ListObjectsV2](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html)。
|
|
202
|
+
|
|
203
|
+
仅当还有条目时返回 `isTruncated: true` 与 `nextContinuationToken`。本地游标无需进程内缓存,绑定规范根目录、`prefix` 和 `delimiter`;相同根目录的独立实例或重启后可续页,也可调整下一页的 `maxKeys`。跨范围、格式非法或版本不支持的游标返回 `INVALID_LIST_OPTIONS`。游标不是授权凭据或目录快照:调用方仍须授权前缀,并发增删可能改变后续页;不支持将不同挂载路径的根目录视为同一范围。本地每次请求仍扫描、排序匹配目录,大目录应缩小前缀或使用 S3。
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
let continuationToken: string | undefined
|
|
207
|
+
do {
|
|
208
|
+
const page = await storage.dir.list({ prefix: 'reports/2026-', maxKeys: 100, continuationToken })
|
|
209
|
+
if (!page.success)
|
|
210
|
+
return page
|
|
211
|
+
// 消费 page.data.files 与 page.data.commonPrefixes。
|
|
212
|
+
continuationToken = page.data.nextContinuationToken
|
|
213
|
+
} while (continuationToken)
|
|
214
|
+
```
|
|
162
215
|
|
|
163
216
|
### 错误处理
|
|
164
217
|
|
|
@@ -173,15 +226,29 @@ if (!result.success && result.error.code === HaiStorageError.NOT_INITIALIZED.cod
|
|
|
173
226
|
|
|
174
227
|
常用错误码:
|
|
175
228
|
|
|
176
|
-
| 错误码
|
|
177
|
-
|
|
|
178
|
-
| `HaiStorageError.NOT_INITIALIZED`
|
|
179
|
-
| `HaiStorageError.CONNECTION_FAILED`
|
|
180
|
-
| `HaiStorageError.OPERATION_FAILED`
|
|
181
|
-
| `HaiStorageError.NOT_FOUND`
|
|
182
|
-
| `HaiStorageError.PERMISSION_DENIED`
|
|
183
|
-
| `HaiStorageError.PRESIGN_FAILED`
|
|
184
|
-
| `HaiStorageError.
|
|
229
|
+
| 错误码 | code | 说明 |
|
|
230
|
+
| --------------------------------------- | ----------------- | --------------------------------------------- |
|
|
231
|
+
| `HaiStorageError.NOT_INITIALIZED` | `hai:storage:010` | 未初始化 |
|
|
232
|
+
| `HaiStorageError.CONNECTION_FAILED` | `hai:storage:001` | 连接失败 |
|
|
233
|
+
| `HaiStorageError.OPERATION_FAILED` | `hai:storage:002` | 操作失败 |
|
|
234
|
+
| `HaiStorageError.NOT_FOUND` | `hai:storage:003` | 文件不存在 |
|
|
235
|
+
| `HaiStorageError.PERMISSION_DENIED` | `hai:storage:005` | 权限不足 |
|
|
236
|
+
| `HaiStorageError.PRESIGN_FAILED` | `hai:storage:013` | 预签名 URL 生成失败 |
|
|
237
|
+
| `HaiStorageError.UNSUPPORTED_OPERATION` | `hai:storage:018` | 当前 Provider 不支持该操作(如 local 预签名) |
|
|
238
|
+
| `HaiStorageError.INVALID_LIST_OPTIONS` | `hai:storage:019` | 本地列表参数或游标无效、游标查询范围不匹配 |
|
|
239
|
+
| `HaiStorageError.CONFIG_ERROR` | `hai:storage:012` | 配置错误 |
|
|
240
|
+
|
|
241
|
+
## 生产共享状态
|
|
242
|
+
|
|
243
|
+
生产使用共享 S3 或兼容服务;local 只用于开发/明确测试。对象业务授权和读改写的条件提交由应用负责。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化发布成功前校验;仅 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试可豁免。 [状态、并发和恢复边界](../../.agents/instructions/distributed-state.md)。
|
|
244
|
+
|
|
245
|
+
## 输入约束与 HTTP 能力
|
|
246
|
+
|
|
247
|
+
存储 key 按 UTF-8 最多 1024 字节,并拒绝绝对路径、反斜线、控制字符及点路径段;公开 HTTP 批量删除每次 1–1000 项。`@h-ai/storage/schemas` 提供浏览器安全的共同规则。预签名有效期为整数 1–604800 秒(Node 默认 3600,HTTP 默认 900);HTTP 上传仅接受 key/contentType/expiresIn,不支持 contentLength/maxSize/metadata/checksum。无效输入返回 HTTP 400,typed client 映射 VALIDATION_FAILED。FileUpload 的大小/类型限制仅为前端体验,实际对象须由应用服务端复核。Node 流式读写/checksum/条件写等没有默认 HTTP 端点。完整映射与授权边界见 [存储 API 能力](README.md#存储输入约束与跨端能力)。
|
|
248
|
+
|
|
249
|
+
## 浏览器上传取消
|
|
250
|
+
|
|
251
|
+
`uploadWithPresignedUrl(url, data, { onProgress, abortController })` 使用原生 XHR 报告进度;不传 `onProgress` 时使用 fetch。预先取消不发送请求,准备阶段和进行中取消、外部 XHR abort、网络/超时及进度回调异常都只结算一次失败,并移除本次监听。完成后再取消不会操作旧 XHR。取消只终止本地请求,不能回滚远端已写入的对象;应用须先核对对象状态,再授权删除。
|
|
185
252
|
|
|
186
253
|
## 开发测试
|
|
187
254
|
|
|
@@ -196,6 +263,50 @@ S3 集成测试需要可用的 Docker 或兼容运行时;同一套初始化、
|
|
|
196
263
|
|
|
197
264
|
容器使用临时生成的测试凭据,等待服务就绪后通过 S3 API 建桶;最后一个租约释放时移除容器及匿名卷。
|
|
198
265
|
|
|
266
|
+
源码 JSDoc 遵循[统一规范](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证):公开示例使用实际发布入口,内部示例在原模块作用域检查。构建后执行 `pnpm check:jsdoc --package storage`;示例编译不代替真实后端行为验证。
|
|
267
|
+
|
|
268
|
+
真实 Chrome 回归:先安装可用的 Chrome,设置 `HAI_STORAGE_RUN_BROWSER_GATES=1` 后执行 `pnpm --filter @h-ai/storage exec vitest run tests/storage-client-browser.test.ts`。该门禁实际发送 HTTP PUT 并检查原生取消和监听清理;缺少浏览器会失败。
|
|
269
|
+
|
|
199
270
|
## 许可证
|
|
200
271
|
|
|
201
272
|
Apache-2.0
|
|
273
|
+
|
|
274
|
+
## 存储输入约束与跨端能力
|
|
275
|
+
|
|
276
|
+
`@h-ai/storage/schemas` 是仅依赖 Zod 的浏览器安全入口;`api-contract` 与 `serv` 复用同一规则。Schema 只校验输入,服务端仍先认证、再按可信 session 授权每个资源,Local Provider 仍执行文件系统防逃逸。调用方不能把前缀校验或分页游标当成授权。
|
|
277
|
+
|
|
278
|
+
### 输入边界
|
|
279
|
+
|
|
280
|
+
- `StorageKeySchema`:非空、不是纯空白,UTF-8 最多 1024 **字节**;不允许开头 `/`、反斜线、控制字符,以及独立的 `.` / `..` 路径段。不截断、不归一化 key。
|
|
281
|
+
- `StoragePrefixSchema`:同样的路径和字节限制,另允许空字符串;允许部分文件名,`report` 不等于目录 `report/`。
|
|
282
|
+
- `StorageDeleteKeysSchema`:每个 HTTP 请求 1–1000 个键,按原始数组数量计数,重复项不能绕过上限。Node `file.deleteMany` 允许更大数组,S3 每批最多 1000、Local 并发最多 32;失败时检查 `error.ext.deleteMany` 的已完成/失败/未知/未尝试进度,不能盲目重放整个数组。
|
|
283
|
+
- 列表 `maxKeys`:整数 1–1000,`files` 与 `commonPrefixes` 共用预算。续页使用响应原样返回的 token,并保持相同 Provider/根目录/prefix/delimiter;不保证并发写入期间的快照。
|
|
284
|
+
- 签名有效期:整数 1–604800 秒,Node 默认 3600 秒,默认 HTTP procedures 使用 900 秒。HTTP 上传签名只接受 `key`、`contentType`、`expiresIn`;下载签名只接受 `key`、`expiresIn`。未知字段直接拒绝,包括未实现的 `contentLength` / `maxSize`,不静默丢弃或假装限制已生效。
|
|
285
|
+
- 输入校验在 HTTP 层返回 400;`apiClient` 返回 `HaiApiClientError.VALIDATION_FAILED`,`error.cause.status` 保留 400。合法输入的业务失败仍按 `HaiResult.success` 判断。
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { StorageDeleteFilesInputSchema } from '@h-ai/api-contract/storage'
|
|
289
|
+
import { StorageKeySchema } from '@h-ai/storage/schemas'
|
|
290
|
+
|
|
291
|
+
const key = StorageKeySchema.safeParse('users/u1/report.pdf')
|
|
292
|
+
if (key.success) {
|
|
293
|
+
const input = StorageDeleteFilesInputSchema.parse({ keys: [key.data] })
|
|
294
|
+
// 把 input 交给已初始化的 client.storage.files.deleteMany;仍须检查返回值 success。
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### 能力映射
|
|
299
|
+
|
|
300
|
+
| Node storage 能力 | api-contract / serv 默认端点 | apiClient / 浏览器消费 | 边界 |
|
|
301
|
+
| -------------------------------------------------- | --------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
302
|
+
| `presign.getUrl` | `POST /storage/presigned-urls/download` | `storage.presignedUrls.createDownload` | 仅 S3;Local 返回 `hai:storage:018`,不返回虚拟 URL |
|
|
303
|
+
| `presign.putUrl` | `POST /storage/presigned-urls/upload` | `storage.presignedUrls.createUpload` 后直接 PUT 到 S3 | 仅内容类型与有效期;不是文件代理上传,也不提供上传大小/内容校验保证 |
|
|
304
|
+
| `dir.list` | `GET /storage/files` | `storage.files.list` | 共享前缀约束与分页预算;逐次授权 prefix |
|
|
305
|
+
| `file.head` | `POST /storage/files/metadata` | `storage.files.getMetadata` | 返回元数据,不能把 ETag 当内容 checksum |
|
|
306
|
+
| `file.delete` | `DELETE /storage/files` | `storage.files.delete` | 逐资源授权 |
|
|
307
|
+
| `file.deleteMany` | `POST /storage/files/delete-many` | `storage.files.deleteMany` | HTTP 总量上限 1000,返回可确认的删除进度 |
|
|
308
|
+
| `file.put/get/getStream/copy/exists`、`dir.delete` | 无默认端点 | 应用自行提供有界且鉴权的服务端流程 | 流、Range、AbortSignal、metadata、cacheControl、条件写等 Node 选项不能直接作为预签名 API 入参 |
|
|
309
|
+
| `checksum.generate/verify` | 无默认端点 | 应用选择明确的浏览器实现或服务端校验流程 | Node 无状态工具;不是上传接口自动校验 |
|
|
310
|
+
| `presign.publicUrl` | 无默认端点 | 应用按公开资源策略使用 | 仅配置公开 S3 地址时有 URL;不是鉴权链接,Local 为 null |
|
|
311
|
+
|
|
312
|
+
`FileUpload` / `ImageUpload` / `AvatarUpload` 的 `uploadHandler` 由应用实现。组件的 `maxSize`、`accept` 只负责前端选择体验,不能约束拿到签名后绕过组件的上传;服务端应在对象落库/回调或独立代理上传处验证实际大小、类型、内容和归属,再发布业务记录。不要把 Node `UploadOptions` 当成签名选项,也不要给组件编造不存在的框架上传端点。
|