@h-ai/storage 0.1.0-alpha9 → 1.0.0-beta.10

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/node.js CHANGED
@@ -1,19 +1,123 @@
1
- import { HaiStorageError, StorageConfigSchema } from './chunk-KHZOR6CR.js';
2
- export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageTypeSchema } from './chunk-KHZOR6CR.js';
3
- import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-U427BQIQ.js';
4
- export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-U427BQIQ.js';
1
+ import { HaiStorageError, StorageConfigSchema, PresignUploadOptionsSchema, PresignOptionsSchema } from './chunk-ANAESRWS.js';
2
+ export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageOperationLogConfigSchema, StorageTypeSchema } from './chunk-ANAESRWS.js';
3
+ import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-TLZY4IP6.js';
4
+ export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-TLZY4IP6.js';
5
+ export { STORAGE_DELETE_MAX_KEYS, STORAGE_KEY_MAX_BYTES, StorageDeleteKeysSchema, StorageKeySchema, StoragePrefixSchema, StoragePresignExpiresInSchema, getStorageKeyIssue } from './chunk-P65WYXJG.js';
6
+ import process from 'node:process';
5
7
  import { core, err, ok } from '@h-ai/core';
6
- import { Buffer } from 'buffer';
7
- import * as crypto from 'crypto';
8
- import * as fs from 'fs';
9
- import * as fsp from 'fs/promises';
10
- import * as path from 'path';
11
- import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectsCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
8
+ import { Buffer } from 'node:buffer';
9
+ import * as crypto from 'node:crypto';
10
+ import { createHash, timingSafeEqual } from 'node:crypto';
11
+ import * as fs from 'node:fs';
12
+ import * as fsp from 'node:fs/promises';
13
+ import * as path from 'node:path';
14
+ import { Readable } from 'node:stream';
15
+ import { pipeline } from 'node:stream/promises';
16
+ import { S3Client, HeadBucketCommand, CreateBucketCommand, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectsCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
12
17
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
13
18
 
19
+ function toStorageDeleteResult(summary) {
20
+ if (summary.failures.length === 0)
21
+ return ok(summary);
22
+ return {
23
+ success: false,
24
+ error: {
25
+ ...HaiStorageError.OPERATION_FAILED,
26
+ message: storageM("storage_deleteManyFailed", { params: { count: summary.failures.length } }),
27
+ ext: { deleteMany: summary }
28
+ }
29
+ };
30
+ }
31
+ async function* toByteStream(data, signal) {
32
+ signal?.throwIfAborted();
33
+ if (typeof data === "string" || data instanceof Uint8Array) {
34
+ yield typeof data === "string" ? Buffer.from(data) : data;
35
+ return;
36
+ }
37
+ const iterator = data[Symbol.asyncIterator]();
38
+ let completed = false;
39
+ try {
40
+ while (true) {
41
+ signal?.throwIfAborted();
42
+ let result;
43
+ let onAbort;
44
+ try {
45
+ const cancelled = signal ? new Promise((_, reject) => {
46
+ onAbort = () => reject(signal.reason);
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ }) : void 0;
49
+ const next = iterator.next();
50
+ result = await (cancelled ? Promise.race([next, cancelled]) : next);
51
+ } finally {
52
+ if (onAbort)
53
+ signal?.removeEventListener("abort", onAbort);
54
+ }
55
+ if (result.done) {
56
+ completed = true;
57
+ break;
58
+ }
59
+ if (!(result.value instanceof Uint8Array))
60
+ throw new TypeError(storageM("storage_streamInvalidChunk"));
61
+ yield result.value;
62
+ }
63
+ signal?.throwIfAborted();
64
+ } finally {
65
+ if (!completed && iterator.return) {
66
+ if (signal?.aborted)
67
+ void Promise.resolve(iterator.return()).catch(() => {
68
+ });
69
+ else
70
+ await iterator.return();
71
+ }
72
+ }
73
+ }
74
+
75
+ // src/providers/storage-operation-log.ts
76
+ function normalizePayloadValue(value) {
77
+ if (value instanceof Uint8Array) {
78
+ return { byteLength: value.byteLength };
79
+ }
80
+ return value;
81
+ }
82
+ function stringifyOperationPayload(payload, maxLength) {
83
+ if (maxLength <= 0) {
84
+ return "";
85
+ }
86
+ try {
87
+ const text = JSON.stringify(payload, (_key, value) => {
88
+ if (typeof value === "bigint") {
89
+ return value.toString();
90
+ }
91
+ if (typeof value === "function") {
92
+ return "[Function]";
93
+ }
94
+ return normalizePayloadValue(value);
95
+ });
96
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
97
+ } catch {
98
+ const text = String(payload);
99
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
100
+ }
101
+ }
102
+ function logStorageOperation(logger4, config, category, operation, payload) {
103
+ if (!config?.[category]) {
104
+ return;
105
+ }
106
+ logger4[config.level]("Storage operation executed", {
107
+ category,
108
+ operation,
109
+ payload: stringifyOperationPayload(payload, config.maxLength)
110
+ });
111
+ }
112
+
113
+ // src/providers/storage-provider-local.ts
14
114
  var logger = core.logger.child({ module: "storage", scope: "provider-local" });
115
+ var PATH_SEGMENT_SPLIT_REGEX = /[\\/]+/;
15
116
  function toStorageError(error, key) {
16
117
  const e = error;
118
+ if (e.code === "EEXIST") {
119
+ return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
120
+ }
17
121
  if (e.code === "ENOENT") {
18
122
  return {
19
123
  ...HaiStorageError.NOT_FOUND,
@@ -63,7 +167,8 @@ function calculateEtag(stat2) {
63
167
  return `"${stat2.size.toString(16)}-${stat2.mtimeMs.toString(16)}"`;
64
168
  }
65
169
  function isMetaFile(key) {
66
- return key.endsWith(".meta.json");
170
+ const normalized = key.toLowerCase();
171
+ return normalized.endsWith(".meta.json") || normalized.endsWith(".upload.tmp");
67
172
  }
68
173
  function createInvalidPathError() {
69
174
  const error = new Error(storageM("storage_pathTraversal"));
@@ -71,14 +176,14 @@ function createInvalidPathError() {
71
176
  return error;
72
177
  }
73
178
  function hasPathTraversalSegment(key) {
74
- const segments = key.split(/[\\/]+/).filter(Boolean);
179
+ const segments = key.split(PATH_SEGMENT_SPLIT_REGEX).filter(Boolean);
75
180
  return segments.includes("..");
76
181
  }
77
182
  function safePath(root, key) {
78
183
  if (path.isAbsolute(key)) {
79
184
  throw createInvalidPathError();
80
185
  }
81
- if (key.includes("\0") || hasPathTraversalSegment(key)) {
186
+ if (key.includes("\0") || hasPathTraversalSegment(key) || process.platform === "win32" && key.split(PATH_SEGMENT_SPLIT_REGEX).some((segment) => /[:. ]$/.test(segment) || segment.includes(":"))) {
82
187
  throw createInvalidPathError();
83
188
  }
84
189
  const normalized = path.normalize(key);
@@ -99,17 +204,25 @@ function createLocalProvider() {
99
204
  }
100
205
  return config;
101
206
  }
102
- function fullPath(key) {
103
- return safePath(getConfig().root, key);
104
- }
105
- function toBuffer(data) {
106
- if (typeof data === "string") {
107
- return Buffer.from(data);
108
- }
109
- if (data instanceof Buffer) {
110
- return data;
207
+ async function fullPath(key) {
208
+ const root = getConfig().root;
209
+ const target = safePath(root, key);
210
+ let current = root;
211
+ for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
212
+ current = path.join(current, segment);
213
+ try {
214
+ if ((await fsp.lstat(current)).isSymbolicLink())
215
+ throw createInvalidPathError();
216
+ } catch (error) {
217
+ if (error.code === "ENOENT")
218
+ break;
219
+ throw error;
220
+ }
111
221
  }
112
- return Buffer.from(data);
222
+ return target;
223
+ }
224
+ function logOperation(category, operation, payload) {
225
+ logStorageOperation(logger, config?.operationLog, category, operation, payload);
113
226
  }
114
227
  const file = {
115
228
  async put(key, data, options = {}) {
@@ -119,13 +232,24 @@ function createLocalProvider() {
119
232
  storageM("storage_metaFileAccess", { params: { key } })
120
233
  );
121
234
  }
235
+ let temporaryPath;
122
236
  try {
123
- const filePath = fullPath(key);
124
- const buffer = toBuffer(data);
125
- logger.debug("Putting file", { key, size: buffer.length });
237
+ const filePath = await fullPath(key);
238
+ const metaPath = await fullPath(`${key}.meta.json`);
239
+ logOperation("write", "file.put", { key, options });
126
240
  const dir2 = path.dirname(filePath);
127
241
  await fsp.mkdir(dir2, { recursive: true, mode: getConfig().directoryMode });
128
- await fsp.writeFile(filePath, buffer, { mode: getConfig().fileMode });
242
+ temporaryPath = `${filePath}.${crypto.randomUUID()}.upload.tmp`;
243
+ await pipeline(
244
+ Readable.from(toByteStream(data, options.signal), { objectMode: false }),
245
+ fs.createWriteStream(temporaryPath, { flags: "wx", mode: getConfig().fileMode }),
246
+ { signal: options.signal }
247
+ );
248
+ options.signal?.throwIfAborted();
249
+ if (options.ifNoneMatch === "*")
250
+ await fsp.link(temporaryPath, filePath);
251
+ else
252
+ await fsp.rename(temporaryPath, filePath);
129
253
  const stat2 = await fsp.stat(filePath);
130
254
  const metadata = {
131
255
  key,
@@ -135,20 +259,38 @@ function createLocalProvider() {
135
259
  etag: calculateEtag(stat2),
136
260
  metadata: options.metadata
137
261
  };
138
- if (options.metadata) {
139
- const metaPath = `${filePath}.meta.json`;
262
+ if (options.metadata || options.contentType) {
140
263
  await fsp.writeFile(metaPath, JSON.stringify({
141
264
  contentType: options.contentType,
142
265
  metadata: options.metadata
143
266
  }));
267
+ } else {
268
+ await fsp.rm(metaPath, { force: true });
144
269
  }
145
270
  return ok(metadata);
146
271
  } catch (error) {
147
272
  logger.warn("Put file failed", { key, error });
148
273
  return err(toStorageError(error, key));
274
+ } finally {
275
+ if (temporaryPath)
276
+ await fsp.unlink(temporaryPath).catch(() => {
277
+ });
149
278
  }
150
279
  },
151
280
  async get(key, options = {}) {
281
+ const streamed = await file.getStream(key, options);
282
+ if (!streamed.success)
283
+ return streamed;
284
+ try {
285
+ const chunks = [];
286
+ for await (const chunk of streamed.data)
287
+ chunks.push(chunk);
288
+ return ok(Buffer.concat(chunks));
289
+ } catch (error) {
290
+ return err(toStorageError(error, key));
291
+ }
292
+ },
293
+ async getStream(key, options = {}) {
152
294
  if (isMetaFile(key)) {
153
295
  return err(
154
296
  HaiStorageError.INVALID_PATH,
@@ -156,21 +298,18 @@ function createLocalProvider() {
156
298
  );
157
299
  }
158
300
  try {
159
- const filePath = fullPath(key);
160
- if (options.rangeStart !== void 0 || options.rangeEnd !== void 0) {
161
- const stat2 = await fsp.stat(filePath);
162
- const start = options.rangeStart ?? 0;
163
- const end = options.rangeEnd !== void 0 ? options.rangeEnd : stat2.size - 1;
164
- return new Promise((resolve2) => {
165
- const chunks = [];
166
- const stream = fs.createReadStream(filePath, { start, end });
167
- stream.on("data", (chunk) => chunks.push(chunk));
168
- stream.on("end", () => resolve2(ok(Buffer.concat(chunks))));
169
- stream.on("error", (error) => resolve2(err(toStorageError(error, key))));
170
- });
301
+ const filePath = await fullPath(key);
302
+ logOperation("read", "file.getStream", { key, options });
303
+ options.signal?.throwIfAborted();
304
+ const stat2 = await fsp.stat(filePath);
305
+ if (stat2.isDirectory())
306
+ return err(HaiStorageError.INVALID_PATH, storageM("storage_pathIsDir", { params: { key } }));
307
+ async function* read() {
308
+ const stream = fs.createReadStream(filePath, { start: options.rangeStart, end: options.rangeEnd, signal: options.signal });
309
+ for await (const chunk of stream)
310
+ yield chunk;
171
311
  }
172
- const data = await fsp.readFile(filePath);
173
- return ok(data);
312
+ return ok(read());
174
313
  } catch (error) {
175
314
  return err(toStorageError(error, key));
176
315
  }
@@ -183,7 +322,8 @@ function createLocalProvider() {
183
322
  );
184
323
  }
185
324
  try {
186
- const filePath = fullPath(key);
325
+ const filePath = await fullPath(key);
326
+ logOperation("read", "file.head", { key });
187
327
  const stat2 = await fsp.stat(filePath);
188
328
  if (stat2.isDirectory()) {
189
329
  return err(
@@ -193,7 +333,7 @@ function createLocalProvider() {
193
333
  }
194
334
  let customMetadata = {};
195
335
  try {
196
- const metaPath = `${filePath}.meta.json`;
336
+ const metaPath = await fullPath(`${key}.meta.json`);
197
337
  const metaContent = await fsp.readFile(metaPath, "utf-8");
198
338
  customMetadata = JSON.parse(metaContent);
199
339
  } catch {
@@ -219,7 +359,8 @@ function createLocalProvider() {
219
359
  );
220
360
  }
221
361
  try {
222
- const filePath = fullPath(key);
362
+ const filePath = await fullPath(key);
363
+ logOperation("read", "file.exists", { key });
223
364
  await fsp.access(filePath, fs.constants.F_OK);
224
365
  return ok(true);
225
366
  } catch (error) {
@@ -237,7 +378,8 @@ function createLocalProvider() {
237
378
  );
238
379
  }
239
380
  try {
240
- const filePath = fullPath(key);
381
+ const filePath = await fullPath(key);
382
+ logOperation("write", "file.delete", { key });
241
383
  logger.debug("Deleting file", { key });
242
384
  await fsp.unlink(filePath);
243
385
  try {
@@ -254,104 +396,121 @@ function createLocalProvider() {
254
396
  }
255
397
  },
256
398
  async deleteMany(keys) {
257
- const results = await Promise.allSettled(keys.map((key) => file.delete(key)));
258
- const errors = [];
259
- for (const result of results) {
260
- if (result.status === "fulfilled" && !result.value.success) {
261
- errors.push(result.value.error);
399
+ logOperation("write", "file.deleteMany", { count: keys.length, keys });
400
+ const summary = { deletedKeys: [], failures: [] };
401
+ const uniqueKeys = [...new Set(keys)];
402
+ for (let offset = 0; offset < uniqueKeys.length; offset += 32) {
403
+ const batch = uniqueKeys.slice(offset, offset + 32);
404
+ const results = await Promise.allSettled(batch.map((key) => file.delete(key)));
405
+ for (const [index, result] of results.entries()) {
406
+ const key = batch[index];
407
+ if (result.status === "rejected")
408
+ summary.failures.push({ key, code: "UnexpectedDeleteFailure", retryable: false, outcome: "failed" });
409
+ else if (!result.value.success)
410
+ summary.failures.push({ key, code: String(result.value.error.code), retryable: false, outcome: "failed" });
411
+ else
412
+ summary.deletedKeys.push(key);
262
413
  }
263
414
  }
264
- if (errors.length > 0) {
265
- return err(
266
- HaiStorageError.OPERATION_FAILED,
267
- storageM("storage_deleteManyFailed", { params: { count: errors.length } }),
268
- errors
269
- );
270
- }
271
- return ok(void 0);
415
+ return toStorageDeleteResult(summary);
272
416
  },
273
417
  async copy(sourceKey, destKey, options = {}) {
274
- if (isMetaFile(sourceKey) || isMetaFile(destKey)) {
275
- return err(
276
- HaiStorageError.INVALID_PATH,
277
- storageM("storage_metaFileAccess", { params: { key: sourceKey } }),
278
- sourceKey
279
- );
280
- }
281
- try {
282
- const sourcePath = fullPath(sourceKey);
283
- const destPath = fullPath(destKey);
284
- logger.debug("Copying file", { sourceKey, destKey });
285
- await fsp.mkdir(path.dirname(destPath), { recursive: true, mode: getConfig().directoryMode });
286
- await fsp.copyFile(sourcePath, destPath);
287
- const stat2 = await fsp.stat(destPath);
288
- const metadata = {
289
- key: destKey,
290
- size: stat2.size,
291
- contentType: options.contentType || getMimeType2(destKey),
292
- lastModified: stat2.mtime,
293
- etag: calculateEtag(stat2),
294
- metadata: options.metadata
295
- };
296
- if (options.contentType || options.metadata) {
297
- const metaPath = `${destPath}.meta.json`;
298
- await fsp.writeFile(metaPath, JSON.stringify({
299
- contentType: options.contentType,
300
- metadata: options.metadata
301
- }));
302
- }
303
- return ok(metadata);
304
- } catch (error) {
305
- return err(toStorageError(error, sourceKey));
306
- }
418
+ logOperation("write", "file.copy", { sourceKey, destKey, options });
419
+ const source = await file.head(sourceKey);
420
+ if (!source.success)
421
+ return source;
422
+ const stream = await file.getStream(sourceKey);
423
+ if (!stream.success)
424
+ return stream;
425
+ return file.put(destKey, stream.data, {
426
+ contentType: options.contentType ?? source.data.contentType,
427
+ metadata: options.metadata ?? source.data.metadata
428
+ });
307
429
  }
308
430
  };
309
431
  const dir = {
310
432
  async list(options = {}) {
311
433
  try {
312
434
  const cfg = getConfig();
313
- const prefix = options.prefix || "";
314
- const delimiter = options.delimiter || "";
315
- const maxKeys = options.maxKeys || 1e3;
316
- const basePath = safePath(cfg.root, prefix);
317
- const files = [];
318
- const commonPrefixes = /* @__PURE__ */ new Set();
319
- async function readDir(dirPath) {
320
- let entries;
435
+ const prefix = options.prefix === void 0 ? "" : options.prefix;
436
+ const delimiter = options.delimiter === void 0 ? "" : options.delimiter;
437
+ const maxKeys = options.maxKeys === void 0 ? 1e3 : options.maxKeys;
438
+ if (typeof prefix !== "string" || typeof delimiter !== "string" || !Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > 1e3 || Buffer.from(prefix).toString("utf8") !== prefix || Buffer.from(delimiter).toString("utf8") !== delimiter) {
439
+ return err(HaiStorageError.INVALID_LIST_OPTIONS, storageM("storage_listOptionsInvalid"));
440
+ }
441
+ logOperation("read", "dir.list", { options });
442
+ safePath(cfg.root, prefix);
443
+ const root = await fsp.realpath(cfg.root);
444
+ const scope = crypto.createHash("sha256").update(JSON.stringify([root, prefix, delimiter])).digest("hex");
445
+ let after = "";
446
+ if (options.continuationToken !== void 0) {
447
+ const token = options.continuationToken;
321
448
  try {
322
- entries = await fsp.readdir(dirPath, { withFileTypes: true });
449
+ if (typeof token !== "string" || token.length === 0 || token.length > 262144)
450
+ throw new Error("Invalid cursor encoding");
451
+ const bytes = Buffer.from(token, "base64url");
452
+ if (bytes.toString("base64url") !== token)
453
+ throw new Error("Invalid cursor encoding");
454
+ const decoded = JSON.parse(bytes.toString("utf8"));
455
+ if (!Array.isArray(decoded) || decoded.length !== 3 || decoded[0] !== 1 || decoded[1] !== scope || typeof decoded[2] !== "string" || decoded[2].length === 0 || !decoded[2].startsWith(prefix)) {
456
+ throw new Error("Invalid cursor scope");
457
+ }
458
+ after = decoded[2];
323
459
  } catch {
324
- return;
460
+ return err(HaiStorageError.INVALID_LIST_OPTIONS, storageM("storage_listCursorInvalid"));
325
461
  }
462
+ }
463
+ const files = [];
464
+ const commonPrefixes = [];
465
+ const compare = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
466
+ async function* readDir(dirPath, directoryKey) {
467
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
468
+ entries.sort((left, right) => compare(left.name + (left.isDirectory() ? "/" : ""), right.name + (right.isDirectory() ? "/" : "")));
326
469
  for (const entry of entries) {
327
- if (files.length >= maxKeys)
328
- return;
329
- const fullEntryPath = path.join(dirPath, entry.name);
330
- const key = path.relative(cfg.root, fullEntryPath).replace(/\\/g, "/");
331
- if (entry.name.endsWith(".meta.json"))
470
+ if (isMetaFile(entry.name))
332
471
  continue;
472
+ const key = directoryKey + entry.name;
473
+ const filePath = path.join(dirPath, entry.name);
333
474
  if (entry.isDirectory()) {
334
- if (delimiter) {
335
- commonPrefixes.add(`${key}/`);
336
- } else {
337
- await readDir(fullEntryPath);
475
+ const childPrefix = `${key}/`;
476
+ if (prefix.startsWith(childPrefix) || childPrefix.startsWith(prefix)) {
477
+ yield* readDir(filePath, childPrefix);
338
478
  }
339
- } else if (entry.isFile()) {
340
- const stat2 = await fsp.stat(fullEntryPath);
341
- files.push({
342
- key,
343
- size: stat2.size,
344
- contentType: getMimeType2(key),
345
- lastModified: stat2.mtime
346
- });
479
+ } else if (entry.isFile() && key.startsWith(prefix)) {
480
+ yield { key, filePath };
347
481
  }
348
482
  }
349
483
  }
350
- await readDir(basePath);
484
+ let previous = "";
485
+ let last = "";
486
+ let count = 0;
487
+ let isTruncated = false;
488
+ for await (const entry of readDir(root, "")) {
489
+ const index = delimiter ? entry.key.indexOf(delimiter, prefix.length) : -1;
490
+ const logicalKey = index < 0 ? entry.key : entry.key.slice(0, index + delimiter.length);
491
+ if (logicalKey === previous)
492
+ continue;
493
+ previous = logicalKey;
494
+ if (compare(logicalKey, after) <= 0)
495
+ continue;
496
+ if (count === maxKeys) {
497
+ isTruncated = true;
498
+ break;
499
+ }
500
+ if (index >= 0) {
501
+ commonPrefixes.push(logicalKey);
502
+ } else {
503
+ const stat2 = await fsp.stat(entry.filePath);
504
+ files.push({ key: entry.key, size: stat2.size, contentType: getMimeType2(entry.key), lastModified: stat2.mtime });
505
+ }
506
+ count++;
507
+ last = logicalKey;
508
+ }
351
509
  return ok({
352
510
  files,
353
- commonPrefixes: Array.from(commonPrefixes),
354
- isTruncated: files.length >= maxKeys
511
+ commonPrefixes,
512
+ isTruncated,
513
+ ...isTruncated ? { nextContinuationToken: Buffer.from(JSON.stringify([1, scope, last])).toString("base64url") } : {}
355
514
  });
356
515
  } catch (error) {
357
516
  return err(toStorageError(error));
@@ -359,7 +518,8 @@ function createLocalProvider() {
359
518
  },
360
519
  async delete(prefix) {
361
520
  try {
362
- const dirPath = fullPath(prefix);
521
+ const dirPath = await fullPath(prefix);
522
+ logOperation("write", "dir.delete", { prefix });
363
523
  await fsp.rm(dirPath, { recursive: true, force: true });
364
524
  return ok(void 0);
365
525
  } catch (error) {
@@ -372,18 +532,15 @@ function createLocalProvider() {
372
532
  };
373
533
  const presign = {
374
534
  async getUrl(key, options) {
375
- const expiresIn = options?.expiresIn || 3600;
376
- const expires = Math.floor(Date.now() / 1e3) + expiresIn;
377
- const signature = crypto.createHash("sha256").update(`${key}:${expires}`).digest("hex").slice(0, 16);
378
- return ok(`local://${key}?expires=${expires}&signature=${signature}`);
535
+ logOperation("read", "presign.getUrl", { key, options });
536
+ return err(HaiStorageError.UNSUPPORTED_OPERATION, storageM("storage_localPresignUnsupported"));
379
537
  },
380
538
  async putUrl(key, options) {
381
- const expiresIn = options?.expiresIn || 3600;
382
- const expires = Math.floor(Date.now() / 1e3) + expiresIn;
383
- const signature = crypto.createHash("sha256").update(`put:${key}:${expires}`).digest("hex").slice(0, 16);
384
- return ok(`local://${key}?action=put&expires=${expires}&signature=${signature}`);
539
+ logOperation("write", "presign.putUrl", { key, options });
540
+ return err(HaiStorageError.UNSUPPORTED_OPERATION, storageM("storage_localPresignUnsupported"));
385
541
  },
386
- publicUrl(_key) {
542
+ publicUrl(key) {
543
+ logOperation("read", "presign.publicUrl", { key });
387
544
  return null;
388
545
  }
389
546
  };
@@ -403,6 +560,7 @@ function createLocalProvider() {
403
560
  config = cfg;
404
561
  logger.info("Connecting local provider", { root: cfg.root });
405
562
  await fsp.mkdir(cfg.root, { recursive: true, mode: cfg.directoryMode });
563
+ config = { ...cfg, root: await fsp.realpath(cfg.root) };
406
564
  connected = true;
407
565
  logger.info("Local provider connected", { root: cfg.root });
408
566
  return ok(void 0);
@@ -423,20 +581,14 @@ function createLocalProvider() {
423
581
  };
424
582
  }
425
583
  var logger2 = core.logger.child({ module: "storage", scope: "provider-s3" });
426
- function sanitizeEndpointUrl(url) {
427
- try {
428
- const parsed = new URL(url);
429
- if (parsed.username)
430
- parsed.username = "***";
431
- if (parsed.password)
432
- parsed.password = "***";
433
- return parsed.toString();
434
- } catch {
435
- return "(invalid url)";
436
- }
437
- }
584
+ var TRAILING_SLASHES_REGEX = /\/+$/;
585
+ var LEADING_SLASHES_REGEX = /^\/+/;
586
+ var DELETE_BATCH_SIZE = 1e3;
587
+ var RETRYABLE_DELETE_CODES = /* @__PURE__ */ new Set(["InternalError", "ServiceUnavailable", "SlowDown", "RequestTimeout"]);
438
588
  function toStorageError2(error, key) {
439
589
  const e = error;
590
+ if (e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412)
591
+ return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
440
592
  if (e.name === "NoSuchKey" || e.Code === "NoSuchKey" || e.name === "NotFound" || e.Code === "NotFound" || e.name === "404" || e.$metadata?.httpStatusCode === 404) {
441
593
  return {
442
594
  ...HaiStorageError.NOT_FOUND,
@@ -474,12 +626,12 @@ function toStorageError2(error, key) {
474
626
  function withPrefix(key, prefix) {
475
627
  if (!prefix)
476
628
  return key;
477
- return `${prefix.replace(/\/+$/, "")}/${key.replace(/^\/+/, "")}`;
629
+ return `${prefix.replace(TRAILING_SLASHES_REGEX, "")}/${key.replace(LEADING_SLASHES_REGEX, "")}`;
478
630
  }
479
631
  function withoutPrefix(key, prefix) {
480
632
  if (!prefix)
481
633
  return key;
482
- const normalizedPrefix = `${prefix.replace(/\/+$/, "")}/`;
634
+ const normalizedPrefix = `${prefix.replace(TRAILING_SLASHES_REGEX, "")}/`;
483
635
  if (key.startsWith(normalizedPrefix)) {
484
636
  return key.slice(normalizedPrefix.length);
485
637
  }
@@ -487,15 +639,57 @@ function withoutPrefix(key, prefix) {
487
639
  }
488
640
  function createS3Provider() {
489
641
  let client = null;
642
+ let deleteClient = null;
643
+ let presignClient = null;
644
+ let publicBasePath = "";
490
645
  let config = null;
491
- function getClient() {
492
- if (!client) {
646
+ let ready = false;
647
+ let closing = null;
648
+ function closeClients() {
649
+ ready = false;
650
+ config = null;
651
+ publicBasePath = "";
652
+ if (closing)
653
+ return closing;
654
+ closing = Promise.resolve().then(() => {
655
+ const failures = [];
656
+ for (const kind of ["normal", "delete", "presign"]) {
657
+ const owned = kind === "normal" ? client : kind === "delete" ? deleteClient : presignClient;
658
+ if (!owned)
659
+ continue;
660
+ try {
661
+ owned.destroy();
662
+ if (kind === "normal")
663
+ client = null;
664
+ else if (kind === "delete")
665
+ deleteClient = null;
666
+ else presignClient = null;
667
+ } catch (error) {
668
+ failures.push(error);
669
+ }
670
+ }
671
+ if (failures.length > 0)
672
+ throw new AggregateError(failures, "S3 client cleanup failed");
673
+ }).finally(() => {
674
+ closing = null;
675
+ });
676
+ return closing;
677
+ }
678
+ function getClient(operation) {
679
+ const selected = operation === "deleteMany" ? deleteClient : operation === "presign" ? presignClient ?? client : client;
680
+ if (!ready || !selected) {
493
681
  throw new Error(storageM("storage_s3ClientNotInitialized"));
494
682
  }
495
- return client;
683
+ return selected;
684
+ }
685
+ function publicPresignedUrl(url, basePath) {
686
+ if (!basePath)
687
+ return url;
688
+ const pathOffset = url.indexOf("/", url.indexOf("://") + 3);
689
+ return `${url.slice(0, pathOffset)}${basePath}${url.slice(pathOffset)}`;
496
690
  }
497
691
  function getConfig() {
498
- if (!config) {
692
+ if (!ready || !config) {
499
693
  throw new Error(storageM("storage_s3ConfigNotInitialized"));
500
694
  }
501
695
  return config;
@@ -503,29 +697,59 @@ function createS3Provider() {
503
697
  function fullKey(key) {
504
698
  return withPrefix(key, getConfig().prefix);
505
699
  }
700
+ function logOperation(category, operation, payload) {
701
+ logStorageOperation(logger2, config?.operationLog, category, operation, payload);
702
+ }
506
703
  const file = {
507
704
  async put(key, data, options = {}) {
508
705
  try {
509
706
  const s3Client = getClient();
510
707
  const s3Config = getConfig();
511
708
  const fullPath = fullKey(key);
709
+ options.signal?.throwIfAborted();
512
710
  const body = typeof data === "string" ? Buffer.from(data) : data;
513
- await s3Client.send(new PutObjectCommand({
711
+ const buffered = body instanceof Uint8Array;
712
+ let size = buffered ? body.byteLength : 0;
713
+ const params = {
514
714
  Bucket: s3Config.bucket,
515
715
  Key: fullPath,
516
- Body: body,
517
716
  ContentType: options.contentType,
518
717
  Metadata: options.metadata,
519
718
  CacheControl: options.cacheControl,
520
- ContentDisposition: options.contentDisposition
521
- }));
719
+ ContentDisposition: options.contentDisposition,
720
+ IfNoneMatch: options.ifNoneMatch
721
+ };
722
+ logOperation("write", "file.put", { key, fullPath, byteLength: buffered ? size : void 0, options });
723
+ if (buffered) {
724
+ await s3Client.send(new PutObjectCommand({ ...params, Body: body }), { abortSignal: options.signal });
725
+ } else {
726
+ async function* counted() {
727
+ for await (const chunk of toByteStream(data, options.signal)) {
728
+ size += chunk.byteLength;
729
+ yield chunk;
730
+ }
731
+ }
732
+ const stream = Readable.from(counted(), { objectMode: false });
733
+ const controller = new AbortController();
734
+ const abort = () => controller.abort();
735
+ options.signal?.addEventListener("abort", abort, { once: true });
736
+ try {
737
+ if (options.signal?.aborted)
738
+ abort();
739
+ const { Upload } = await import('@aws-sdk/lib-storage');
740
+ await new Upload({ client: s3Client, params: { ...params, Body: stream }, queueSize: 2, leavePartsOnError: false, abortController: controller }).done();
741
+ } finally {
742
+ options.signal?.removeEventListener("abort", abort);
743
+ stream.destroy();
744
+ }
745
+ }
522
746
  const headResult = await file.head(key);
523
747
  if (headResult.success) {
524
748
  return headResult;
525
749
  }
526
750
  return ok({
527
751
  key,
528
- size: body.length,
752
+ size,
529
753
  contentType: options.contentType || "application/octet-stream",
530
754
  lastModified: /* @__PURE__ */ new Date()
531
755
  });
@@ -534,10 +758,24 @@ function createS3Provider() {
534
758
  }
535
759
  },
536
760
  async get(key, options = {}) {
761
+ const streamed = await file.getStream(key, options);
762
+ if (!streamed.success)
763
+ return streamed;
764
+ try {
765
+ const chunks = [];
766
+ for await (const chunk of streamed.data)
767
+ chunks.push(chunk);
768
+ return ok(Buffer.concat(chunks));
769
+ } catch (error) {
770
+ return err(toStorageError2(error, key));
771
+ }
772
+ },
773
+ async getStream(key, options = {}) {
537
774
  try {
538
775
  const s3Client = getClient();
539
776
  const s3Config = getConfig();
540
777
  const fullPath = fullKey(key);
778
+ logOperation("read", "file.getStream", { key, fullPath, options });
541
779
  let Range;
542
780
  if (options.rangeStart !== void 0 || options.rangeEnd !== void 0) {
543
781
  const start = options.rangeStart ?? 0;
@@ -548,19 +786,15 @@ function createS3Provider() {
548
786
  Bucket: s3Config.bucket,
549
787
  Key: fullPath,
550
788
  Range
551
- }));
789
+ }), { abortSignal: options.signal });
552
790
  if (!response.Body) {
553
791
  return err(
554
792
  HaiStorageError.OPERATION_FAILED,
555
793
  storageM("storage_responseBodyEmpty")
556
794
  );
557
795
  }
558
- const chunks = [];
559
796
  const stream = response.Body;
560
- for await (const chunk of stream) {
561
- chunks.push(chunk);
562
- }
563
- return ok(Buffer.concat(chunks));
797
+ return ok(stream);
564
798
  } catch (error) {
565
799
  return err(toStorageError2(error, key));
566
800
  }
@@ -570,6 +804,7 @@ function createS3Provider() {
570
804
  const s3Client = getClient();
571
805
  const s3Config = getConfig();
572
806
  const fullPath = fullKey(key);
807
+ logOperation("read", "file.head", { key, fullPath });
573
808
  const response = await s3Client.send(new HeadObjectCommand({
574
809
  Bucket: s3Config.bucket,
575
810
  Key: fullPath
@@ -587,6 +822,7 @@ function createS3Provider() {
587
822
  }
588
823
  },
589
824
  async exists(key) {
825
+ logOperation("read", "file.exists", { key });
590
826
  const result = await file.head(key);
591
827
  if (result.success) {
592
828
  return ok(true);
@@ -601,6 +837,7 @@ function createS3Provider() {
601
837
  const s3Client = getClient();
602
838
  const s3Config = getConfig();
603
839
  const fullPath = fullKey(key);
840
+ logOperation("write", "file.delete", { key, fullPath });
604
841
  await s3Client.send(new DeleteObjectCommand({
605
842
  Bucket: s3Config.bucket,
606
843
  Key: fullPath
@@ -612,18 +849,65 @@ function createS3Provider() {
612
849
  },
613
850
  async deleteMany(keys) {
614
851
  try {
615
- const s3Client = getClient();
852
+ const s3Client = getClient("deleteMany");
616
853
  const s3Config = getConfig();
617
- if (keys.length === 0) {
618
- return ok(void 0);
619
- }
620
- await s3Client.send(new DeleteObjectsCommand({
621
- Bucket: s3Config.bucket,
622
- Delete: {
623
- Objects: keys.map((key) => ({ Key: fullKey(key) }))
854
+ logOperation("write", "file.deleteMany", { count: keys.length, keys });
855
+ const uniqueKeys = [...new Set(keys)];
856
+ const summary = { deletedKeys: [], failures: [] };
857
+ for (let offset = 0; offset < uniqueKeys.length; offset += DELETE_BATCH_SIZE) {
858
+ let pending = uniqueKeys.slice(offset, offset + DELETE_BATCH_SIZE);
859
+ for (let attempt = 0; pending.length > 0 && attempt < 3; attempt++) {
860
+ let response;
861
+ try {
862
+ response = await s3Client.send(new DeleteObjectsCommand({
863
+ Bucket: s3Config.bucket,
864
+ Delete: { Objects: [...new Set(pending.map(fullKey))].map((Key) => ({ Key })), Quiet: false }
865
+ }));
866
+ } catch (cause) {
867
+ const failure = cause && typeof cause === "object" ? cause : {};
868
+ const status = failure.$metadata?.httpStatusCode;
869
+ const code = failure.name && /^[\w.-]{1,80}$/.test(failure.name) ? failure.name : "RequestFailed";
870
+ const retryable = status === void 0 || status === 408 || status === 429 || status >= 500;
871
+ const outcome = status !== void 0 && status >= 400 && status < 500 && status !== 408 ? "failed" : "unknown";
872
+ summary.failures.push(...pending.map((key) => ({ key, code, retryable, outcome })));
873
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code, retryable, outcome: "notAttempted" })));
874
+ return toStorageDeleteResult(summary);
875
+ }
876
+ const outcomes = /* @__PURE__ */ new Map();
877
+ const requested = new Set(pending.map(fullKey));
878
+ let malformed = false;
879
+ const items = [
880
+ ...(response.Deleted ?? []).map((item) => ({ key: item.Key, code: null })),
881
+ ...(response.Errors ?? []).map((item) => ({ key: item.Key, code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError" }))
882
+ ];
883
+ for (const item of items) {
884
+ if (!item.key || !requested.has(item.key) || outcomes.has(item.key)) {
885
+ malformed = true;
886
+ break;
887
+ }
888
+ outcomes.set(item.key, item.code);
889
+ }
890
+ if (malformed || outcomes.size !== requested.size) {
891
+ summary.failures.push(...pending.map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "unknown" })));
892
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "notAttempted" })));
893
+ return toStorageDeleteResult(summary);
894
+ }
895
+ const retry = [];
896
+ for (const key of pending) {
897
+ const code = outcomes.get(fullKey(key));
898
+ if (code === null)
899
+ summary.deletedKeys.push(key);
900
+ else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2)
901
+ retry.push(key);
902
+ else
903
+ summary.failures.push({ key, code: code ?? "UnknownError", retryable: !!code && RETRYABLE_DELETE_CODES.has(code), outcome: "failed" });
904
+ }
905
+ pending = retry;
906
+ if (pending.length > 0)
907
+ await new Promise((resolve2) => setTimeout(resolve2, 100 * 2 ** attempt));
624
908
  }
625
- }));
626
- return ok(void 0);
909
+ }
910
+ return toStorageDeleteResult(summary);
627
911
  } catch (error) {
628
912
  return err(toStorageError2(error));
629
913
  }
@@ -634,6 +918,7 @@ function createS3Provider() {
634
918
  const s3Config = getConfig();
635
919
  const sourceFullPath = fullKey(sourceKey);
636
920
  const destFullPath = fullKey(destKey);
921
+ logOperation("write", "file.copy", { sourceKey, destKey, sourceFullPath, destFullPath, options });
637
922
  await s3Client.send(new CopyObjectCommand({
638
923
  Bucket: s3Config.bucket,
639
924
  CopySource: `${s3Config.bucket}/${sourceFullPath}`,
@@ -653,6 +938,7 @@ function createS3Provider() {
653
938
  try {
654
939
  const s3Client = getClient();
655
940
  const s3Config = getConfig();
941
+ logOperation("read", "dir.list", { options });
656
942
  let prefix = s3Config.prefix || "";
657
943
  if (options.prefix) {
658
944
  prefix = withPrefix(options.prefix, s3Config.prefix);
@@ -685,6 +971,7 @@ function createS3Provider() {
685
971
  },
686
972
  async delete(prefix) {
687
973
  try {
974
+ logOperation("write", "dir.delete", { prefix });
688
975
  let continuationToken;
689
976
  do {
690
977
  const listResult = await dir.list({
@@ -713,19 +1000,22 @@ function createS3Provider() {
713
1000
  const presign = {
714
1001
  async getUrl(key, options) {
715
1002
  try {
716
- const s3Client = getClient();
1003
+ const parsed = PresignOptionsSchema.parse(options ?? {});
1004
+ const s3Client = getClient("presign");
717
1005
  const s3Config = getConfig();
718
1006
  const fullPath = fullKey(key);
1007
+ logOperation("read", "presign.getUrl", { key, fullPath, options });
1008
+ const basePath = publicBasePath;
719
1009
  const command = new GetObjectCommand({
720
1010
  Bucket: s3Config.bucket,
721
1011
  Key: fullPath,
722
- ResponseContentType: options?.responseContentType,
723
- ResponseContentDisposition: options?.responseContentDisposition
1012
+ ResponseContentType: parsed.responseContentType,
1013
+ ResponseContentDisposition: parsed.responseContentDisposition
724
1014
  });
725
1015
  const url = await getSignedUrl(s3Client, command, {
726
- expiresIn: options?.expiresIn || 3600
1016
+ expiresIn: parsed.expiresIn
727
1017
  });
728
- return ok(url);
1018
+ return ok(publicPresignedUrl(url, basePath));
729
1019
  } catch (error) {
730
1020
  return err(
731
1021
  HaiStorageError.PRESIGN_FAILED,
@@ -736,18 +1026,21 @@ function createS3Provider() {
736
1026
  },
737
1027
  async putUrl(key, options) {
738
1028
  try {
739
- const s3Client = getClient();
1029
+ const parsed = PresignUploadOptionsSchema.parse(options ?? {});
1030
+ const s3Client = getClient("presign");
740
1031
  const s3Config = getConfig();
741
1032
  const fullPath = fullKey(key);
1033
+ logOperation("write", "presign.putUrl", { key, fullPath, options });
1034
+ const basePath = publicBasePath;
742
1035
  const command = new PutObjectCommand({
743
1036
  Bucket: s3Config.bucket,
744
1037
  Key: fullPath,
745
- ContentType: options?.contentType || "application/octet-stream"
1038
+ ContentType: parsed.contentType
746
1039
  });
747
1040
  const url = await getSignedUrl(s3Client, command, {
748
- expiresIn: options?.expiresIn || 3600
1041
+ expiresIn: parsed.expiresIn
749
1042
  });
750
- return ok(url);
1043
+ return ok(publicPresignedUrl(url, basePath));
751
1044
  } catch (error) {
752
1045
  return err(
753
1046
  HaiStorageError.PRESIGN_FAILED,
@@ -762,7 +1055,8 @@ function createS3Provider() {
762
1055
  return null;
763
1056
  }
764
1057
  const fullPath = fullKey(key);
765
- return `${s3Config.publicUrl.replace(/\/+$/, "")}/${fullPath}`;
1058
+ logOperation("read", "presign.publicUrl", { key, fullPath });
1059
+ return `${s3Config.publicUrl.replace(TRAILING_SLASHES_REGEX, "")}/${fullPath}`;
766
1060
  }
767
1061
  };
768
1062
  return {
@@ -778,53 +1072,149 @@ function createS3Provider() {
778
1072
  );
779
1073
  }
780
1074
  try {
1075
+ await closeClients();
781
1076
  config = cfg;
782
1077
  logger2.info("Connecting S3 provider", {
783
1078
  bucket: cfg.bucket,
784
1079
  region: cfg.region,
785
- endpoint: cfg.endpoint ? sanitizeEndpointUrl(cfg.endpoint) : void 0
1080
+ endpoint: cfg.endpoint ? core.sanitize.sanitizeUrl(cfg.endpoint) : void 0
786
1081
  });
787
- client = new S3Client({
1082
+ const clientConfig = {
788
1083
  region: cfg.region,
789
1084
  endpoint: cfg.endpoint,
790
1085
  forcePathStyle: cfg.forcePathStyle,
1086
+ // 不强制兼容服务实现 AWS checksum 扩展;校验工具按实际字节独立计算。
1087
+ requestChecksumCalculation: "WHEN_REQUIRED",
1088
+ responseChecksumValidation: "WHEN_REQUIRED",
791
1089
  credentials: {
792
1090
  accessKeyId: cfg.accessKeyId,
793
1091
  secretAccessKey: cfg.secretAccessKey
794
1092
  }
795
- });
1093
+ };
1094
+ client = new S3Client(clientConfig);
1095
+ deleteClient = new S3Client({ ...clientConfig, maxAttempts: 1 });
1096
+ if (cfg.publicEndpoint) {
1097
+ const endpoint = new URL(cfg.publicEndpoint);
1098
+ publicBasePath = endpoint.pathname.replace(TRAILING_SLASHES_REGEX, "");
1099
+ presignClient = new S3Client({ ...clientConfig, endpoint: endpoint.origin, forcePathStyle: publicBasePath ? true : cfg.forcePathStyle });
1100
+ }
1101
+ if (cfg.createBucket) {
1102
+ try {
1103
+ await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
1104
+ } catch (error) {
1105
+ if (!(error && typeof error === "object" && "$metadata" in error && error.$metadata && typeof error.$metadata === "object" && "httpStatusCode" in error.$metadata && error.$metadata.httpStatusCode === 404)) {
1106
+ throw error;
1107
+ }
1108
+ try {
1109
+ await client.send(new CreateBucketCommand({
1110
+ Bucket: cfg.bucket,
1111
+ // AWS us-east-1 要省略 LocationConstraint;兼容服务可使用自定义区域名。
1112
+ ...cfg.region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: cfg.region } }
1113
+ }));
1114
+ logger2.info("S3 bucket created", { bucket: cfg.bucket });
1115
+ } catch (createError) {
1116
+ if (!(createError instanceof Error && createError.name === "BucketAlreadyOwnedByYou"))
1117
+ throw createError;
1118
+ await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
1119
+ }
1120
+ }
1121
+ }
796
1122
  await client.send(new ListObjectsV2Command({
797
1123
  Bucket: cfg.bucket,
798
1124
  MaxKeys: 1
799
1125
  }));
1126
+ config = cfg;
1127
+ ready = true;
800
1128
  logger2.info("S3 provider connected", { bucket: cfg.bucket });
801
1129
  return ok(void 0);
802
1130
  } catch (error) {
803
1131
  logger2.error("S3 provider connect failed", { error });
804
- config = null;
805
- client = null;
806
- return err(toStorageError2(error));
807
- }
808
- },
809
- async close() {
810
- if (client) {
811
- client.destroy();
812
- client = null;
813
- config = null;
814
- logger2.info("S3 provider disconnected");
1132
+ const original = toStorageError2(error);
1133
+ try {
1134
+ await closeClients();
1135
+ return err(original);
1136
+ } catch (cleanupError) {
1137
+ return err({ ...original, ext: { ...original.ext, cleanupPending: true, cleanupError } });
1138
+ }
815
1139
  }
816
1140
  },
1141
+ close: closeClients,
817
1142
  isConnected() {
818
- return client !== null && config !== null;
1143
+ return ready && client !== null && deleteClient !== null && config !== null;
819
1144
  }
820
1145
  };
821
1146
  }
1147
+ var HEX_CHECKSUM = /^[\da-f]+$/i;
1148
+ var BASE64_CHECKSUM = /^(?:[A-Z\d+/]{4})*(?:[A-Z\d+/]{2}==|[A-Z\d+/]{3}=)?$/i;
1149
+ var ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512", "sha1", "md5"]);
1150
+ async function generate(data, options = {}) {
1151
+ try {
1152
+ const algorithm = options.algorithm ?? "sha256";
1153
+ if (!ALGORITHMS.has(algorithm))
1154
+ return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
1155
+ const hash = createHash(algorithm);
1156
+ for await (const chunk of toByteStream(data, options.signal))
1157
+ hash.update(chunk);
1158
+ return ok(hash.digest(options.encoding ?? "hex"));
1159
+ } catch (error) {
1160
+ return err(HaiStorageError.CHECKSUM_FAILED, storageM("storage_checksumFailed"), error);
1161
+ }
1162
+ }
1163
+ var checksum = {
1164
+ generate,
1165
+ async verify(data, expected, options = {}) {
1166
+ const encoding = options.encoding ?? "hex";
1167
+ const valid = encoding === "hex" ? expected.length % 2 === 0 && HEX_CHECKSUM.test(expected) : BASE64_CHECKSUM.test(expected) && expected.length > 0;
1168
+ if (!valid)
1169
+ return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
1170
+ const calculated = await generate(data, options);
1171
+ if (!calculated.success)
1172
+ return calculated;
1173
+ const actualBytes = Buffer.from(calculated.data, encoding);
1174
+ const expectedBytes = Buffer.from(expected, encoding);
1175
+ return ok(actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes));
1176
+ }
1177
+ };
822
1178
 
823
1179
  // src/storage-main.ts
824
1180
  var logger3 = core.logger.child({ module: "storage", scope: "main" });
825
1181
  var currentProvider = null;
826
1182
  var currentConfig = null;
827
1183
  var initInProgress = false;
1184
+ var pendingProvider = null;
1185
+ var initCompletion = null;
1186
+ var closingPromise = null;
1187
+ async function closeOwnedProvider() {
1188
+ if (currentProvider) {
1189
+ pendingProvider = currentProvider;
1190
+ currentProvider = null;
1191
+ }
1192
+ currentConfig = null;
1193
+ if (!pendingProvider)
1194
+ return ok(void 0);
1195
+ try {
1196
+ await pendingProvider.close();
1197
+ pendingProvider = null;
1198
+ return ok(void 0);
1199
+ } catch (error) {
1200
+ logger3.error("Provider cleanup failed; retaining resource for retry", { error });
1201
+ return err({ ...HaiStorageError.CONNECTION_FAILED, message: storageM("storage_operationFailed", {
1202
+ params: { error: error instanceof Error ? error.message : String(error) }
1203
+ }), cause: error, ext: { cleanupPending: true } });
1204
+ }
1205
+ }
1206
+ async function failInitialization(result) {
1207
+ const cleanup = await closeOwnedProvider();
1208
+ if (result.success)
1209
+ return result;
1210
+ if (cleanup.success) {
1211
+ const ext = { ...result.error.ext };
1212
+ delete ext.cleanupPending;
1213
+ delete ext.cleanupError;
1214
+ return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
1215
+ }
1216
+ return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
1217
+ }
828
1218
  function createProvider(config) {
829
1219
  switch (config.type) {
830
1220
  case "s3":
@@ -849,17 +1239,27 @@ var notInitializedPresign = new Proxy(
849
1239
  }
850
1240
  );
851
1241
  var storage = {
1242
+ checksum,
852
1243
  /**
853
1244
  * 初始化存储连接。
854
1245
  *
855
1246
  * 如果当前已有活跃连接,会先自动 close 再重新初始化。
856
- * 配置会通过 Zod Schema 校验;校验失败或连接异常会返回 `CONNECTION_FAILED`。
1247
+ * 配置通过 Zod Schema 校验;校验、连接或清理失败均返回 HaiResult,不发布失败候选。
857
1248
  *
858
1249
  * @param config 存储配置(S3 或本地),支持省略带默认值的字段。
859
1250
  * @returns 成功时返回 ok(undefined);失败时返回包含错误码和消息的 err。
1251
+ * @example
1252
+ * ```ts
1253
+ * import { storage } from '@h-ai/storage'
1254
+ *
1255
+ * const initialized = await storage.init({ type: 'local', root: './data/uploads' })
1256
+ * if (!initialized.success) throw new Error(initialized.error.message)
1257
+ * const closed = await storage.close()
1258
+ * if (!closed.success) throw new Error(closed.error.message)
1259
+ * ```
860
1260
  */
861
1261
  async init(config) {
862
- if (initInProgress) {
1262
+ if (initInProgress || closingPromise) {
863
1263
  logger3.warn("Storage init already in progress, skipping concurrent call");
864
1264
  return err(
865
1265
  HaiStorageError.OPERATION_FAILED,
@@ -867,11 +1267,14 @@ var storage = {
867
1267
  );
868
1268
  }
869
1269
  initInProgress = true;
1270
+ let resolveInit;
1271
+ initCompletion = new Promise((resolve2) => {
1272
+ resolveInit = resolve2;
1273
+ });
870
1274
  try {
871
- if (currentProvider) {
872
- logger3.warn("Storage module is already initialized, reinitializing");
873
- await storage.close();
874
- }
1275
+ const cleanup = await closeOwnedProvider();
1276
+ if (!cleanup.success)
1277
+ return cleanup;
875
1278
  logger3.info("Initializing storage module");
876
1279
  const parseResult = StorageConfigSchema.safeParse(config);
877
1280
  if (!parseResult.success) {
@@ -883,30 +1286,36 @@ var storage = {
883
1286
  );
884
1287
  }
885
1288
  const parsed = parseResult.data;
1289
+ if (core.module.requiresSharedState(process.env) && parsed.type === "local")
1290
+ return err(HaiStorageError.CONFIG_ERROR, storageM("storage_sharedBackendRequired"));
886
1291
  const provider = createProvider(parsed);
1292
+ pendingProvider = provider;
887
1293
  const connectResult = await provider.connect(parsed);
888
1294
  if (!connectResult.success) {
889
1295
  logger3.error("Storage module initialization failed", {
890
1296
  code: connectResult.error.code,
891
1297
  message: connectResult.error.message
892
1298
  });
893
- return connectResult;
1299
+ return await failInitialization(connectResult);
894
1300
  }
1301
+ pendingProvider = null;
895
1302
  currentProvider = provider;
896
1303
  currentConfig = parsed;
897
1304
  logger3.info("Storage module initialized");
898
1305
  return ok(void 0);
899
1306
  } catch (error) {
900
1307
  logger3.error("Storage module initialization failed", { error });
901
- return err(
1308
+ return await failInitialization(err(
902
1309
  HaiStorageError.CONNECTION_FAILED,
903
1310
  storageM("storage_operationFailed", {
904
1311
  params: { error: error instanceof Error ? error.message : String(error) }
905
1312
  }),
906
1313
  error
907
- );
1314
+ ));
908
1315
  } finally {
909
1316
  initInProgress = false;
1317
+ initCompletion = null;
1318
+ resolveInit();
910
1319
  }
911
1320
  },
912
1321
  /** 文件操作接口。未初始化时所有方法返回 NOT_INITIALIZED 错误 */
@@ -921,36 +1330,39 @@ var storage = {
921
1330
  get presign() {
922
1331
  return currentProvider?.presign ?? notInitializedPresign;
923
1332
  },
924
- /** 当前解析后的存储配置;未初始化或已关闭时为 null */
1333
+ /** 当前脱敏后的存储配置快照;未初始化或已关闭时为 null */
925
1334
  get config() {
926
- return currentConfig;
1335
+ return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
927
1336
  },
928
1337
  /** 是否已完成初始化 */
929
1338
  get isInitialized() {
930
- return currentProvider !== null;
1339
+ return currentProvider !== null && currentProvider.isConnected();
931
1340
  },
932
1341
  /**
933
1342
  * 关闭存储连接并释放资源
934
1343
  *
935
1344
  * 关闭后 file/dir/presign 操作将返回 NOT_INITIALIZED 错误。
936
- * 重复调用不会报错。
1345
+ * 等待初始化并合并并发关闭;失败时保留资源供 close/init 重试。
1346
+ * @returns 所有客户端释放成功才返回成功。
1347
+ * @example
1348
+ * ```ts
1349
+ * import { storage } from '@h-ai/storage'
1350
+ *
1351
+ * const closed = await storage.close()
1352
+ * if (!closed.success) throw new Error(closed.error.message)
1353
+ * ```
937
1354
  */
938
1355
  async close() {
939
- if (!currentProvider) {
940
- currentConfig = null;
941
- logger3.info("Storage module already closed, skipping");
942
- return;
943
- }
944
- logger3.info("Closing storage module");
945
- try {
946
- await currentProvider.close();
947
- logger3.info("Storage module closed");
948
- } catch (error) {
949
- logger3.error("Storage module close failed", { error });
950
- } finally {
951
- currentProvider = null;
952
- currentConfig = null;
1356
+ if (!closingPromise) {
1357
+ closingPromise = (async () => {
1358
+ if (initCompletion)
1359
+ await initCompletion;
1360
+ return await closeOwnedProvider();
1361
+ })().finally(() => {
1362
+ closingPromise = null;
1363
+ });
953
1364
  }
1365
+ return await closingPromise;
954
1366
  }
955
1367
  };
956
1368