@h-ai/storage 0.1.0-alpha9 → 1.0.0-beta.2
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 +491 -102
- package/dist/browser.js +3 -2
- package/dist/{chunk-KHZOR6CR.js → chunk-ANAESRWS.js} +39 -12
- package/dist/chunk-ANAESRWS.js.map +1 -0
- package/dist/chunk-P65WYXJG.js +33 -0
- package/dist/chunk-P65WYXJG.js.map +1 -0
- package/dist/chunk-TLZY4IP6.js +360 -0
- package/dist/chunk-TLZY4IP6.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 +597 -180
- 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-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
|
|
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,57 @@ function withoutPrefix(key, prefix) {
|
|
|
487
644
|
}
|
|
488
645
|
function createS3Provider() {
|
|
489
646
|
let client = null;
|
|
647
|
+
let deleteClient = null;
|
|
648
|
+
let presignClient = null;
|
|
649
|
+
let publicBasePath = "";
|
|
490
650
|
let config = null;
|
|
491
|
-
|
|
492
|
-
|
|
651
|
+
let ready = false;
|
|
652
|
+
let closing = null;
|
|
653
|
+
function closeClients() {
|
|
654
|
+
ready = false;
|
|
655
|
+
config = null;
|
|
656
|
+
publicBasePath = "";
|
|
657
|
+
if (closing)
|
|
658
|
+
return closing;
|
|
659
|
+
closing = Promise.resolve().then(() => {
|
|
660
|
+
const failures = [];
|
|
661
|
+
for (const kind of ["normal", "delete", "presign"]) {
|
|
662
|
+
const owned = kind === "normal" ? client : kind === "delete" ? deleteClient : presignClient;
|
|
663
|
+
if (!owned)
|
|
664
|
+
continue;
|
|
665
|
+
try {
|
|
666
|
+
owned.destroy();
|
|
667
|
+
if (kind === "normal")
|
|
668
|
+
client = null;
|
|
669
|
+
else if (kind === "delete")
|
|
670
|
+
deleteClient = null;
|
|
671
|
+
else presignClient = null;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
failures.push(error);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (failures.length > 0)
|
|
677
|
+
throw new AggregateError(failures, "S3 client cleanup failed");
|
|
678
|
+
}).finally(() => {
|
|
679
|
+
closing = null;
|
|
680
|
+
});
|
|
681
|
+
return closing;
|
|
682
|
+
}
|
|
683
|
+
function getClient(operation) {
|
|
684
|
+
const selected = operation === "deleteMany" ? deleteClient : operation === "presign" ? presignClient ?? client : client;
|
|
685
|
+
if (!ready || !selected) {
|
|
493
686
|
throw new Error(storageM("storage_s3ClientNotInitialized"));
|
|
494
687
|
}
|
|
495
|
-
return
|
|
688
|
+
return selected;
|
|
689
|
+
}
|
|
690
|
+
function publicPresignedUrl(url, basePath) {
|
|
691
|
+
if (!basePath)
|
|
692
|
+
return url;
|
|
693
|
+
const pathOffset = url.indexOf("/", url.indexOf("://") + 3);
|
|
694
|
+
return `${url.slice(0, pathOffset)}${basePath}${url.slice(pathOffset)}`;
|
|
496
695
|
}
|
|
497
696
|
function getConfig() {
|
|
498
|
-
if (!config) {
|
|
697
|
+
if (!ready || !config) {
|
|
499
698
|
throw new Error(storageM("storage_s3ConfigNotInitialized"));
|
|
500
699
|
}
|
|
501
700
|
return config;
|
|
@@ -503,29 +702,59 @@ function createS3Provider() {
|
|
|
503
702
|
function fullKey(key) {
|
|
504
703
|
return withPrefix(key, getConfig().prefix);
|
|
505
704
|
}
|
|
705
|
+
function logOperation(category, operation, payload) {
|
|
706
|
+
logStorageOperation(logger2, config?.operationLog, category, operation, payload);
|
|
707
|
+
}
|
|
506
708
|
const file = {
|
|
507
709
|
async put(key, data, options = {}) {
|
|
508
710
|
try {
|
|
509
711
|
const s3Client = getClient();
|
|
510
712
|
const s3Config = getConfig();
|
|
511
713
|
const fullPath = fullKey(key);
|
|
714
|
+
options.signal?.throwIfAborted();
|
|
512
715
|
const body = typeof data === "string" ? Buffer.from(data) : data;
|
|
513
|
-
|
|
716
|
+
const buffered = body instanceof Uint8Array;
|
|
717
|
+
let size = buffered ? body.byteLength : 0;
|
|
718
|
+
const params = {
|
|
514
719
|
Bucket: s3Config.bucket,
|
|
515
720
|
Key: fullPath,
|
|
516
|
-
Body: body,
|
|
517
721
|
ContentType: options.contentType,
|
|
518
722
|
Metadata: options.metadata,
|
|
519
723
|
CacheControl: options.cacheControl,
|
|
520
|
-
ContentDisposition: options.contentDisposition
|
|
521
|
-
|
|
724
|
+
ContentDisposition: options.contentDisposition,
|
|
725
|
+
IfNoneMatch: options.ifNoneMatch
|
|
726
|
+
};
|
|
727
|
+
logOperation("write", "file.put", { key, fullPath, byteLength: buffered ? size : void 0, options });
|
|
728
|
+
if (buffered) {
|
|
729
|
+
await s3Client.send(new PutObjectCommand({ ...params, Body: body }), { abortSignal: options.signal });
|
|
730
|
+
} else {
|
|
731
|
+
async function* counted() {
|
|
732
|
+
for await (const chunk of toByteStream(data, options.signal)) {
|
|
733
|
+
size += chunk.byteLength;
|
|
734
|
+
yield chunk;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const stream = Readable.from(counted(), { objectMode: false });
|
|
738
|
+
const controller = new AbortController();
|
|
739
|
+
const abort = () => controller.abort();
|
|
740
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
741
|
+
try {
|
|
742
|
+
if (options.signal?.aborted)
|
|
743
|
+
abort();
|
|
744
|
+
const { Upload } = await import('@aws-sdk/lib-storage');
|
|
745
|
+
await new Upload({ client: s3Client, params: { ...params, Body: stream }, queueSize: 2, leavePartsOnError: false, abortController: controller }).done();
|
|
746
|
+
} finally {
|
|
747
|
+
options.signal?.removeEventListener("abort", abort);
|
|
748
|
+
stream.destroy();
|
|
749
|
+
}
|
|
750
|
+
}
|
|
522
751
|
const headResult = await file.head(key);
|
|
523
752
|
if (headResult.success) {
|
|
524
753
|
return headResult;
|
|
525
754
|
}
|
|
526
755
|
return ok({
|
|
527
756
|
key,
|
|
528
|
-
size
|
|
757
|
+
size,
|
|
529
758
|
contentType: options.contentType || "application/octet-stream",
|
|
530
759
|
lastModified: /* @__PURE__ */ new Date()
|
|
531
760
|
});
|
|
@@ -534,10 +763,24 @@ function createS3Provider() {
|
|
|
534
763
|
}
|
|
535
764
|
},
|
|
536
765
|
async get(key, options = {}) {
|
|
766
|
+
const streamed = await file.getStream(key, options);
|
|
767
|
+
if (!streamed.success)
|
|
768
|
+
return streamed;
|
|
769
|
+
try {
|
|
770
|
+
const chunks = [];
|
|
771
|
+
for await (const chunk of streamed.data)
|
|
772
|
+
chunks.push(chunk);
|
|
773
|
+
return ok(Buffer.concat(chunks));
|
|
774
|
+
} catch (error) {
|
|
775
|
+
return err(toStorageError2(error, key));
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
async getStream(key, options = {}) {
|
|
537
779
|
try {
|
|
538
780
|
const s3Client = getClient();
|
|
539
781
|
const s3Config = getConfig();
|
|
540
782
|
const fullPath = fullKey(key);
|
|
783
|
+
logOperation("read", "file.getStream", { key, fullPath, options });
|
|
541
784
|
let Range;
|
|
542
785
|
if (options.rangeStart !== void 0 || options.rangeEnd !== void 0) {
|
|
543
786
|
const start = options.rangeStart ?? 0;
|
|
@@ -548,19 +791,15 @@ function createS3Provider() {
|
|
|
548
791
|
Bucket: s3Config.bucket,
|
|
549
792
|
Key: fullPath,
|
|
550
793
|
Range
|
|
551
|
-
}));
|
|
794
|
+
}), { abortSignal: options.signal });
|
|
552
795
|
if (!response.Body) {
|
|
553
796
|
return err(
|
|
554
797
|
HaiStorageError.OPERATION_FAILED,
|
|
555
798
|
storageM("storage_responseBodyEmpty")
|
|
556
799
|
);
|
|
557
800
|
}
|
|
558
|
-
const chunks = [];
|
|
559
801
|
const stream = response.Body;
|
|
560
|
-
|
|
561
|
-
chunks.push(chunk);
|
|
562
|
-
}
|
|
563
|
-
return ok(Buffer.concat(chunks));
|
|
802
|
+
return ok(stream);
|
|
564
803
|
} catch (error) {
|
|
565
804
|
return err(toStorageError2(error, key));
|
|
566
805
|
}
|
|
@@ -570,6 +809,7 @@ function createS3Provider() {
|
|
|
570
809
|
const s3Client = getClient();
|
|
571
810
|
const s3Config = getConfig();
|
|
572
811
|
const fullPath = fullKey(key);
|
|
812
|
+
logOperation("read", "file.head", { key, fullPath });
|
|
573
813
|
const response = await s3Client.send(new HeadObjectCommand({
|
|
574
814
|
Bucket: s3Config.bucket,
|
|
575
815
|
Key: fullPath
|
|
@@ -587,6 +827,7 @@ function createS3Provider() {
|
|
|
587
827
|
}
|
|
588
828
|
},
|
|
589
829
|
async exists(key) {
|
|
830
|
+
logOperation("read", "file.exists", { key });
|
|
590
831
|
const result = await file.head(key);
|
|
591
832
|
if (result.success) {
|
|
592
833
|
return ok(true);
|
|
@@ -601,6 +842,7 @@ function createS3Provider() {
|
|
|
601
842
|
const s3Client = getClient();
|
|
602
843
|
const s3Config = getConfig();
|
|
603
844
|
const fullPath = fullKey(key);
|
|
845
|
+
logOperation("write", "file.delete", { key, fullPath });
|
|
604
846
|
await s3Client.send(new DeleteObjectCommand({
|
|
605
847
|
Bucket: s3Config.bucket,
|
|
606
848
|
Key: fullPath
|
|
@@ -612,18 +854,65 @@ function createS3Provider() {
|
|
|
612
854
|
},
|
|
613
855
|
async deleteMany(keys) {
|
|
614
856
|
try {
|
|
615
|
-
const s3Client = getClient();
|
|
857
|
+
const s3Client = getClient("deleteMany");
|
|
616
858
|
const s3Config = getConfig();
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
859
|
+
logOperation("write", "file.deleteMany", { count: keys.length, keys });
|
|
860
|
+
const uniqueKeys = [...new Set(keys)];
|
|
861
|
+
const summary = { deletedKeys: [], failures: [] };
|
|
862
|
+
for (let offset = 0; offset < uniqueKeys.length; offset += DELETE_BATCH_SIZE) {
|
|
863
|
+
let pending = uniqueKeys.slice(offset, offset + DELETE_BATCH_SIZE);
|
|
864
|
+
for (let attempt = 0; pending.length > 0 && attempt < 3; attempt++) {
|
|
865
|
+
let response;
|
|
866
|
+
try {
|
|
867
|
+
response = await s3Client.send(new DeleteObjectsCommand({
|
|
868
|
+
Bucket: s3Config.bucket,
|
|
869
|
+
Delete: { Objects: [...new Set(pending.map(fullKey))].map((Key) => ({ Key })), Quiet: false }
|
|
870
|
+
}));
|
|
871
|
+
} catch (cause) {
|
|
872
|
+
const failure = cause && typeof cause === "object" ? cause : {};
|
|
873
|
+
const status = failure.$metadata?.httpStatusCode;
|
|
874
|
+
const code = failure.name && /^[\w.-]{1,80}$/.test(failure.name) ? failure.name : "RequestFailed";
|
|
875
|
+
const retryable = status === void 0 || status === 408 || status === 429 || status >= 500;
|
|
876
|
+
const outcome = status !== void 0 && status >= 400 && status < 500 && status !== 408 ? "failed" : "unknown";
|
|
877
|
+
summary.failures.push(...pending.map((key) => ({ key, code, retryable, outcome })));
|
|
878
|
+
summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code, retryable, outcome: "notAttempted" })));
|
|
879
|
+
return toStorageDeleteResult(summary);
|
|
880
|
+
}
|
|
881
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
882
|
+
const requested = new Set(pending.map(fullKey));
|
|
883
|
+
let malformed = false;
|
|
884
|
+
const items = [
|
|
885
|
+
...(response.Deleted ?? []).map((item) => ({ key: item.Key, code: null })),
|
|
886
|
+
...(response.Errors ?? []).map((item) => ({ key: item.Key, code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError" }))
|
|
887
|
+
];
|
|
888
|
+
for (const item of items) {
|
|
889
|
+
if (!item.key || !requested.has(item.key) || outcomes.has(item.key)) {
|
|
890
|
+
malformed = true;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
outcomes.set(item.key, item.code);
|
|
894
|
+
}
|
|
895
|
+
if (malformed || outcomes.size !== requested.size) {
|
|
896
|
+
summary.failures.push(...pending.map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "unknown" })));
|
|
897
|
+
summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "notAttempted" })));
|
|
898
|
+
return toStorageDeleteResult(summary);
|
|
899
|
+
}
|
|
900
|
+
const retry = [];
|
|
901
|
+
for (const key of pending) {
|
|
902
|
+
const code = outcomes.get(fullKey(key));
|
|
903
|
+
if (code === null)
|
|
904
|
+
summary.deletedKeys.push(key);
|
|
905
|
+
else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2)
|
|
906
|
+
retry.push(key);
|
|
907
|
+
else
|
|
908
|
+
summary.failures.push({ key, code: code ?? "UnknownError", retryable: !!code && RETRYABLE_DELETE_CODES.has(code), outcome: "failed" });
|
|
909
|
+
}
|
|
910
|
+
pending = retry;
|
|
911
|
+
if (pending.length > 0)
|
|
912
|
+
await new Promise((resolve2) => setTimeout(resolve2, 100 * 2 ** attempt));
|
|
624
913
|
}
|
|
625
|
-
}
|
|
626
|
-
return
|
|
914
|
+
}
|
|
915
|
+
return toStorageDeleteResult(summary);
|
|
627
916
|
} catch (error) {
|
|
628
917
|
return err(toStorageError2(error));
|
|
629
918
|
}
|
|
@@ -634,6 +923,7 @@ function createS3Provider() {
|
|
|
634
923
|
const s3Config = getConfig();
|
|
635
924
|
const sourceFullPath = fullKey(sourceKey);
|
|
636
925
|
const destFullPath = fullKey(destKey);
|
|
926
|
+
logOperation("write", "file.copy", { sourceKey, destKey, sourceFullPath, destFullPath, options });
|
|
637
927
|
await s3Client.send(new CopyObjectCommand({
|
|
638
928
|
Bucket: s3Config.bucket,
|
|
639
929
|
CopySource: `${s3Config.bucket}/${sourceFullPath}`,
|
|
@@ -653,6 +943,7 @@ function createS3Provider() {
|
|
|
653
943
|
try {
|
|
654
944
|
const s3Client = getClient();
|
|
655
945
|
const s3Config = getConfig();
|
|
946
|
+
logOperation("read", "dir.list", { options });
|
|
656
947
|
let prefix = s3Config.prefix || "";
|
|
657
948
|
if (options.prefix) {
|
|
658
949
|
prefix = withPrefix(options.prefix, s3Config.prefix);
|
|
@@ -685,6 +976,7 @@ function createS3Provider() {
|
|
|
685
976
|
},
|
|
686
977
|
async delete(prefix) {
|
|
687
978
|
try {
|
|
979
|
+
logOperation("write", "dir.delete", { prefix });
|
|
688
980
|
let continuationToken;
|
|
689
981
|
do {
|
|
690
982
|
const listResult = await dir.list({
|
|
@@ -713,19 +1005,22 @@ function createS3Provider() {
|
|
|
713
1005
|
const presign = {
|
|
714
1006
|
async getUrl(key, options) {
|
|
715
1007
|
try {
|
|
716
|
-
const
|
|
1008
|
+
const parsed = PresignOptionsSchema.parse(options ?? {});
|
|
1009
|
+
const s3Client = getClient("presign");
|
|
717
1010
|
const s3Config = getConfig();
|
|
718
1011
|
const fullPath = fullKey(key);
|
|
1012
|
+
logOperation("read", "presign.getUrl", { key, fullPath, options });
|
|
1013
|
+
const basePath = publicBasePath;
|
|
719
1014
|
const command = new GetObjectCommand({
|
|
720
1015
|
Bucket: s3Config.bucket,
|
|
721
1016
|
Key: fullPath,
|
|
722
|
-
ResponseContentType:
|
|
723
|
-
ResponseContentDisposition:
|
|
1017
|
+
ResponseContentType: parsed.responseContentType,
|
|
1018
|
+
ResponseContentDisposition: parsed.responseContentDisposition
|
|
724
1019
|
});
|
|
725
1020
|
const url = await getSignedUrl(s3Client, command, {
|
|
726
|
-
expiresIn:
|
|
1021
|
+
expiresIn: parsed.expiresIn
|
|
727
1022
|
});
|
|
728
|
-
return ok(url);
|
|
1023
|
+
return ok(publicPresignedUrl(url, basePath));
|
|
729
1024
|
} catch (error) {
|
|
730
1025
|
return err(
|
|
731
1026
|
HaiStorageError.PRESIGN_FAILED,
|
|
@@ -736,18 +1031,21 @@ function createS3Provider() {
|
|
|
736
1031
|
},
|
|
737
1032
|
async putUrl(key, options) {
|
|
738
1033
|
try {
|
|
739
|
-
const
|
|
1034
|
+
const parsed = PresignUploadOptionsSchema.parse(options ?? {});
|
|
1035
|
+
const s3Client = getClient("presign");
|
|
740
1036
|
const s3Config = getConfig();
|
|
741
1037
|
const fullPath = fullKey(key);
|
|
1038
|
+
logOperation("write", "presign.putUrl", { key, fullPath, options });
|
|
1039
|
+
const basePath = publicBasePath;
|
|
742
1040
|
const command = new PutObjectCommand({
|
|
743
1041
|
Bucket: s3Config.bucket,
|
|
744
1042
|
Key: fullPath,
|
|
745
|
-
ContentType:
|
|
1043
|
+
ContentType: parsed.contentType
|
|
746
1044
|
});
|
|
747
1045
|
const url = await getSignedUrl(s3Client, command, {
|
|
748
|
-
expiresIn:
|
|
1046
|
+
expiresIn: parsed.expiresIn
|
|
749
1047
|
});
|
|
750
|
-
return ok(url);
|
|
1048
|
+
return ok(publicPresignedUrl(url, basePath));
|
|
751
1049
|
} catch (error) {
|
|
752
1050
|
return err(
|
|
753
1051
|
HaiStorageError.PRESIGN_FAILED,
|
|
@@ -762,7 +1060,8 @@ function createS3Provider() {
|
|
|
762
1060
|
return null;
|
|
763
1061
|
}
|
|
764
1062
|
const fullPath = fullKey(key);
|
|
765
|
-
|
|
1063
|
+
logOperation("read", "presign.publicUrl", { key, fullPath });
|
|
1064
|
+
return `${s3Config.publicUrl.replace(TRAILING_SLASHES_REGEX, "")}/${fullPath}`;
|
|
766
1065
|
}
|
|
767
1066
|
};
|
|
768
1067
|
return {
|
|
@@ -778,53 +1077,149 @@ function createS3Provider() {
|
|
|
778
1077
|
);
|
|
779
1078
|
}
|
|
780
1079
|
try {
|
|
1080
|
+
await closeClients();
|
|
781
1081
|
config = cfg;
|
|
782
1082
|
logger2.info("Connecting S3 provider", {
|
|
783
1083
|
bucket: cfg.bucket,
|
|
784
1084
|
region: cfg.region,
|
|
785
|
-
endpoint: cfg.endpoint ?
|
|
1085
|
+
endpoint: cfg.endpoint ? core.sanitize.sanitizeUrl(cfg.endpoint) : void 0
|
|
786
1086
|
});
|
|
787
|
-
|
|
1087
|
+
const clientConfig = {
|
|
788
1088
|
region: cfg.region,
|
|
789
1089
|
endpoint: cfg.endpoint,
|
|
790
1090
|
forcePathStyle: cfg.forcePathStyle,
|
|
1091
|
+
// 不强制兼容服务实现 AWS checksum 扩展;校验工具按实际字节独立计算。
|
|
1092
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
1093
|
+
responseChecksumValidation: "WHEN_REQUIRED",
|
|
791
1094
|
credentials: {
|
|
792
1095
|
accessKeyId: cfg.accessKeyId,
|
|
793
1096
|
secretAccessKey: cfg.secretAccessKey
|
|
794
1097
|
}
|
|
795
|
-
}
|
|
1098
|
+
};
|
|
1099
|
+
client = new S3Client(clientConfig);
|
|
1100
|
+
deleteClient = new S3Client({ ...clientConfig, maxAttempts: 1 });
|
|
1101
|
+
if (cfg.publicEndpoint) {
|
|
1102
|
+
const endpoint = new URL(cfg.publicEndpoint);
|
|
1103
|
+
publicBasePath = endpoint.pathname.replace(TRAILING_SLASHES_REGEX, "");
|
|
1104
|
+
presignClient = new S3Client({ ...clientConfig, endpoint: endpoint.origin, forcePathStyle: publicBasePath ? true : cfg.forcePathStyle });
|
|
1105
|
+
}
|
|
1106
|
+
if (cfg.createBucket) {
|
|
1107
|
+
try {
|
|
1108
|
+
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (!(error && typeof error === "object" && "$metadata" in error && error.$metadata && typeof error.$metadata === "object" && "httpStatusCode" in error.$metadata && error.$metadata.httpStatusCode === 404)) {
|
|
1111
|
+
throw error;
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
await client.send(new CreateBucketCommand({
|
|
1115
|
+
Bucket: cfg.bucket,
|
|
1116
|
+
// AWS us-east-1 要省略 LocationConstraint;兼容服务可使用自定义区域名。
|
|
1117
|
+
...cfg.region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: cfg.region } }
|
|
1118
|
+
}));
|
|
1119
|
+
logger2.info("S3 bucket created", { bucket: cfg.bucket });
|
|
1120
|
+
} catch (createError) {
|
|
1121
|
+
if (!(createError instanceof Error && createError.name === "BucketAlreadyOwnedByYou"))
|
|
1122
|
+
throw createError;
|
|
1123
|
+
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
796
1127
|
await client.send(new ListObjectsV2Command({
|
|
797
1128
|
Bucket: cfg.bucket,
|
|
798
1129
|
MaxKeys: 1
|
|
799
1130
|
}));
|
|
1131
|
+
config = cfg;
|
|
1132
|
+
ready = true;
|
|
800
1133
|
logger2.info("S3 provider connected", { bucket: cfg.bucket });
|
|
801
1134
|
return ok(void 0);
|
|
802
1135
|
} catch (error) {
|
|
803
1136
|
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");
|
|
1137
|
+
const original = toStorageError2(error);
|
|
1138
|
+
try {
|
|
1139
|
+
await closeClients();
|
|
1140
|
+
return err(original);
|
|
1141
|
+
} catch (cleanupError) {
|
|
1142
|
+
return err({ ...original, ext: { ...original.ext, cleanupPending: true, cleanupError } });
|
|
1143
|
+
}
|
|
815
1144
|
}
|
|
816
1145
|
},
|
|
1146
|
+
close: closeClients,
|
|
817
1147
|
isConnected() {
|
|
818
|
-
return client !== null && config !== null;
|
|
1148
|
+
return ready && client !== null && deleteClient !== null && config !== null;
|
|
819
1149
|
}
|
|
820
1150
|
};
|
|
821
1151
|
}
|
|
1152
|
+
var HEX_CHECKSUM = /^[\da-f]+$/i;
|
|
1153
|
+
var BASE64_CHECKSUM = /^(?:[A-Z\d+/]{4})*(?:[A-Z\d+/]{2}==|[A-Z\d+/]{3}=)?$/i;
|
|
1154
|
+
var ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512", "sha1", "md5"]);
|
|
1155
|
+
async function generate(data, options = {}) {
|
|
1156
|
+
try {
|
|
1157
|
+
const algorithm = options.algorithm ?? "sha256";
|
|
1158
|
+
if (!ALGORITHMS.has(algorithm))
|
|
1159
|
+
return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
|
|
1160
|
+
const hash = createHash(algorithm);
|
|
1161
|
+
for await (const chunk of toByteStream(data, options.signal))
|
|
1162
|
+
hash.update(chunk);
|
|
1163
|
+
return ok(hash.digest(options.encoding ?? "hex"));
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
return err(HaiStorageError.CHECKSUM_FAILED, storageM("storage_checksumFailed"), error);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
var checksum = {
|
|
1169
|
+
generate,
|
|
1170
|
+
async verify(data, expected, options = {}) {
|
|
1171
|
+
const encoding = options.encoding ?? "hex";
|
|
1172
|
+
const valid = encoding === "hex" ? expected.length % 2 === 0 && HEX_CHECKSUM.test(expected) : BASE64_CHECKSUM.test(expected) && expected.length > 0;
|
|
1173
|
+
if (!valid)
|
|
1174
|
+
return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
|
|
1175
|
+
const calculated = await generate(data, options);
|
|
1176
|
+
if (!calculated.success)
|
|
1177
|
+
return calculated;
|
|
1178
|
+
const actualBytes = Buffer.from(calculated.data, encoding);
|
|
1179
|
+
const expectedBytes = Buffer.from(expected, encoding);
|
|
1180
|
+
return ok(actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes));
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
822
1183
|
|
|
823
1184
|
// src/storage-main.ts
|
|
824
1185
|
var logger3 = core.logger.child({ module: "storage", scope: "main" });
|
|
825
1186
|
var currentProvider = null;
|
|
826
1187
|
var currentConfig = null;
|
|
827
1188
|
var initInProgress = false;
|
|
1189
|
+
var pendingProvider = null;
|
|
1190
|
+
var initCompletion = null;
|
|
1191
|
+
var closingPromise = null;
|
|
1192
|
+
async function closeOwnedProvider() {
|
|
1193
|
+
if (currentProvider) {
|
|
1194
|
+
pendingProvider = currentProvider;
|
|
1195
|
+
currentProvider = null;
|
|
1196
|
+
}
|
|
1197
|
+
currentConfig = null;
|
|
1198
|
+
if (!pendingProvider)
|
|
1199
|
+
return ok(void 0);
|
|
1200
|
+
try {
|
|
1201
|
+
await pendingProvider.close();
|
|
1202
|
+
pendingProvider = null;
|
|
1203
|
+
return ok(void 0);
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
logger3.error("Provider cleanup failed; retaining resource for retry", { error });
|
|
1206
|
+
return err({ ...HaiStorageError.CONNECTION_FAILED, message: storageM("storage_operationFailed", {
|
|
1207
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
1208
|
+
}), cause: error, ext: { cleanupPending: true } });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
async function failInitialization(result) {
|
|
1212
|
+
const cleanup = await closeOwnedProvider();
|
|
1213
|
+
if (result.success)
|
|
1214
|
+
return result;
|
|
1215
|
+
if (cleanup.success) {
|
|
1216
|
+
const ext = { ...result.error.ext };
|
|
1217
|
+
delete ext.cleanupPending;
|
|
1218
|
+
delete ext.cleanupError;
|
|
1219
|
+
return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
|
|
1220
|
+
}
|
|
1221
|
+
return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
|
|
1222
|
+
}
|
|
828
1223
|
function createProvider(config) {
|
|
829
1224
|
switch (config.type) {
|
|
830
1225
|
case "s3":
|
|
@@ -849,17 +1244,27 @@ var notInitializedPresign = new Proxy(
|
|
|
849
1244
|
}
|
|
850
1245
|
);
|
|
851
1246
|
var storage = {
|
|
1247
|
+
checksum,
|
|
852
1248
|
/**
|
|
853
1249
|
* 初始化存储连接。
|
|
854
1250
|
*
|
|
855
1251
|
* 如果当前已有活跃连接,会先自动 close 再重新初始化。
|
|
856
|
-
*
|
|
1252
|
+
* 配置通过 Zod Schema 校验;校验、连接或清理失败均返回 HaiResult,不发布失败候选。
|
|
857
1253
|
*
|
|
858
1254
|
* @param config 存储配置(S3 或本地),支持省略带默认值的字段。
|
|
859
1255
|
* @returns 成功时返回 ok(undefined);失败时返回包含错误码和消息的 err。
|
|
1256
|
+
* @example
|
|
1257
|
+
* ```ts
|
|
1258
|
+
* import { storage } from '@h-ai/storage'
|
|
1259
|
+
*
|
|
1260
|
+
* const initialized = await storage.init({ type: 'local', root: './data/uploads' })
|
|
1261
|
+
* if (!initialized.success) throw new Error(initialized.error.message)
|
|
1262
|
+
* const closed = await storage.close()
|
|
1263
|
+
* if (!closed.success) throw new Error(closed.error.message)
|
|
1264
|
+
* ```
|
|
860
1265
|
*/
|
|
861
1266
|
async init(config) {
|
|
862
|
-
if (initInProgress) {
|
|
1267
|
+
if (initInProgress || closingPromise) {
|
|
863
1268
|
logger3.warn("Storage init already in progress, skipping concurrent call");
|
|
864
1269
|
return err(
|
|
865
1270
|
HaiStorageError.OPERATION_FAILED,
|
|
@@ -867,11 +1272,14 @@ var storage = {
|
|
|
867
1272
|
);
|
|
868
1273
|
}
|
|
869
1274
|
initInProgress = true;
|
|
1275
|
+
let resolveInit;
|
|
1276
|
+
initCompletion = new Promise((resolve2) => {
|
|
1277
|
+
resolveInit = resolve2;
|
|
1278
|
+
});
|
|
870
1279
|
try {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
1280
|
+
const cleanup = await closeOwnedProvider();
|
|
1281
|
+
if (!cleanup.success)
|
|
1282
|
+
return cleanup;
|
|
875
1283
|
logger3.info("Initializing storage module");
|
|
876
1284
|
const parseResult = StorageConfigSchema.safeParse(config);
|
|
877
1285
|
if (!parseResult.success) {
|
|
@@ -883,30 +1291,36 @@ var storage = {
|
|
|
883
1291
|
);
|
|
884
1292
|
}
|
|
885
1293
|
const parsed = parseResult.data;
|
|
1294
|
+
if (core.module.requiresSharedState(process.env) && parsed.type === "local")
|
|
1295
|
+
return err(HaiStorageError.CONFIG_ERROR, storageM("storage_sharedBackendRequired"));
|
|
886
1296
|
const provider = createProvider(parsed);
|
|
1297
|
+
pendingProvider = provider;
|
|
887
1298
|
const connectResult = await provider.connect(parsed);
|
|
888
1299
|
if (!connectResult.success) {
|
|
889
1300
|
logger3.error("Storage module initialization failed", {
|
|
890
1301
|
code: connectResult.error.code,
|
|
891
1302
|
message: connectResult.error.message
|
|
892
1303
|
});
|
|
893
|
-
return connectResult;
|
|
1304
|
+
return await failInitialization(connectResult);
|
|
894
1305
|
}
|
|
1306
|
+
pendingProvider = null;
|
|
895
1307
|
currentProvider = provider;
|
|
896
1308
|
currentConfig = parsed;
|
|
897
1309
|
logger3.info("Storage module initialized");
|
|
898
1310
|
return ok(void 0);
|
|
899
1311
|
} catch (error) {
|
|
900
1312
|
logger3.error("Storage module initialization failed", { error });
|
|
901
|
-
return err(
|
|
1313
|
+
return await failInitialization(err(
|
|
902
1314
|
HaiStorageError.CONNECTION_FAILED,
|
|
903
1315
|
storageM("storage_operationFailed", {
|
|
904
1316
|
params: { error: error instanceof Error ? error.message : String(error) }
|
|
905
1317
|
}),
|
|
906
1318
|
error
|
|
907
|
-
);
|
|
1319
|
+
));
|
|
908
1320
|
} finally {
|
|
909
1321
|
initInProgress = false;
|
|
1322
|
+
initCompletion = null;
|
|
1323
|
+
resolveInit();
|
|
910
1324
|
}
|
|
911
1325
|
},
|
|
912
1326
|
/** 文件操作接口。未初始化时所有方法返回 NOT_INITIALIZED 错误 */
|
|
@@ -921,36 +1335,39 @@ var storage = {
|
|
|
921
1335
|
get presign() {
|
|
922
1336
|
return currentProvider?.presign ?? notInitializedPresign;
|
|
923
1337
|
},
|
|
924
|
-
/**
|
|
1338
|
+
/** 当前脱敏后的存储配置快照;未初始化或已关闭时为 null */
|
|
925
1339
|
get config() {
|
|
926
|
-
return currentConfig;
|
|
1340
|
+
return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
|
|
927
1341
|
},
|
|
928
1342
|
/** 是否已完成初始化 */
|
|
929
1343
|
get isInitialized() {
|
|
930
|
-
return currentProvider !== null;
|
|
1344
|
+
return currentProvider !== null && currentProvider.isConnected();
|
|
931
1345
|
},
|
|
932
1346
|
/**
|
|
933
1347
|
* 关闭存储连接并释放资源
|
|
934
1348
|
*
|
|
935
1349
|
* 关闭后 file/dir/presign 操作将返回 NOT_INITIALIZED 错误。
|
|
936
|
-
*
|
|
1350
|
+
* 等待初始化并合并并发关闭;失败时保留资源供 close/init 重试。
|
|
1351
|
+
* @returns 所有客户端释放成功才返回成功。
|
|
1352
|
+
* @example
|
|
1353
|
+
* ```ts
|
|
1354
|
+
* import { storage } from '@h-ai/storage'
|
|
1355
|
+
*
|
|
1356
|
+
* const closed = await storage.close()
|
|
1357
|
+
* if (!closed.success) throw new Error(closed.error.message)
|
|
1358
|
+
* ```
|
|
937
1359
|
*/
|
|
938
1360
|
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;
|
|
1361
|
+
if (!closingPromise) {
|
|
1362
|
+
closingPromise = (async () => {
|
|
1363
|
+
if (initCompletion)
|
|
1364
|
+
await initCompletion;
|
|
1365
|
+
return await closeOwnedProvider();
|
|
1366
|
+
})().finally(() => {
|
|
1367
|
+
closingPromise = null;
|
|
1368
|
+
});
|
|
953
1369
|
}
|
|
1370
|
+
return await closingPromise;
|
|
954
1371
|
}
|
|
955
1372
|
};
|
|
956
1373
|
|