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