s3mini 0.1.0
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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/s3mini.d.ts +187 -0
- package/dist/s3mini.js +841 -0
- package/dist/s3mini.js.map +1 -0
- package/dist/s3mini.min.js +2 -0
- package/dist/s3mini.min.js.map +1 -0
- package/package.json +112 -0
- package/src/S3.ts +831 -0
- package/src/consts.ts +38 -0
- package/src/index.ts +20 -0
- package/src/types.ts +88 -0
- package/src/utils.ts +197 -0
package/src/consts.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Constants
|
|
2
|
+
export const AWS_ALGORITHM = 'AWS4-HMAC-SHA256';
|
|
3
|
+
export const AWS_REQUEST_TYPE = 'aws4_request';
|
|
4
|
+
export const S3_SERVICE = 's3';
|
|
5
|
+
export const LIST_TYPE = '2';
|
|
6
|
+
export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
|
7
|
+
export const DEFAULT_STREAM_CONTENT_TYPE = 'application/octet-stream';
|
|
8
|
+
export const XML_CONTENT_TYPE = 'application/xml';
|
|
9
|
+
export const JSON_CONTENT_TYPE = 'application/json';
|
|
10
|
+
// List of keys that might contain sensitive information
|
|
11
|
+
export const SENSITIVE_KEYS_REDACTED = ['accessKeyId', 'secretAccessKey', 'sessionToken', 'password', 'token'];
|
|
12
|
+
export const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
// Headers
|
|
15
|
+
export const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
|
|
16
|
+
export const HEADER_AMZ_DATE = 'x-amz-date';
|
|
17
|
+
export const HEADER_HOST = 'host';
|
|
18
|
+
export const HEADER_AUTHORIZATION = 'Authorization';
|
|
19
|
+
export const HEADER_CONTENT_TYPE = 'Content-Type';
|
|
20
|
+
export const HEADER_CONTENT_LENGTH = 'Content-Length';
|
|
21
|
+
export const HEADER_ETAG = 'etag';
|
|
22
|
+
export const HEADER_LAST_MODIFIED = 'last-modified';
|
|
23
|
+
|
|
24
|
+
// Error messages
|
|
25
|
+
export const ERROR_PREFIX = '[s3mini] ';
|
|
26
|
+
export const ERROR_ACCESS_KEY_REQUIRED = `${ERROR_PREFIX}accessKeyId must be a non-empty string`;
|
|
27
|
+
export const ERROR_SECRET_KEY_REQUIRED = `${ERROR_PREFIX}secretAccessKey must be a non-empty string`;
|
|
28
|
+
export const ERROR_ENDPOINT_REQUIRED = `${ERROR_PREFIX}endpoint must be a non-empty string`;
|
|
29
|
+
export const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expected format: https://<host>[:port][/base-path]`;
|
|
30
|
+
export const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`;
|
|
31
|
+
export const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`;
|
|
32
|
+
export const ERROR_PARTS_REQUIRED = `${ERROR_PREFIX}parts must be a non-empty array`;
|
|
33
|
+
export const ERROR_INVALID_PART = `${ERROR_PREFIX}Each part must have a partNumber (number) and ETag (string)`;
|
|
34
|
+
export const ERROR_DATA_BUFFER_REQUIRED = `${ERROR_PREFIX}data must be a Buffer or string`;
|
|
35
|
+
// const ERROR_PATH_REQUIRED = `${ERROR_PREFIX}path must be a string`;
|
|
36
|
+
export const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`;
|
|
37
|
+
export const ERROR_MAX_KEYS_TYPE = `${ERROR_PREFIX}maxKeys must be a positive integer`;
|
|
38
|
+
export const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { s3mini } from './S3.js';
|
|
4
|
+
import { sanitizeETag, runInBatches } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// Export the S3 class as default export and named export
|
|
7
|
+
export { s3mini, sanitizeETag, runInBatches };
|
|
8
|
+
export default s3mini;
|
|
9
|
+
|
|
10
|
+
// Re-export types
|
|
11
|
+
export type {
|
|
12
|
+
S3Config,
|
|
13
|
+
Logger,
|
|
14
|
+
UploadPart,
|
|
15
|
+
CompleteMultipartUploadResult,
|
|
16
|
+
ExistResponseCode,
|
|
17
|
+
ListBucketResponse,
|
|
18
|
+
ListMultipartUploadResponse,
|
|
19
|
+
ErrorWithCode,
|
|
20
|
+
} from './types.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { createHash as NodeCreateHash, createHmac as NodeCreateHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface S3Config {
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
secretAccessKey: string;
|
|
6
|
+
endpoint: string;
|
|
7
|
+
region?: string;
|
|
8
|
+
requestSizeInBytes?: number;
|
|
9
|
+
requestAbortTimeout?: number;
|
|
10
|
+
logger?: Logger;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Crypto {
|
|
14
|
+
createHmac: typeof NodeCreateHmac;
|
|
15
|
+
createHash: typeof NodeCreateHash;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Logger {
|
|
19
|
+
info: (message: string, ...args: unknown[]) => void;
|
|
20
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
21
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UploadPart {
|
|
25
|
+
partNumber: number;
|
|
26
|
+
etag: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CompleteMultipartUploadResult {
|
|
30
|
+
location: string;
|
|
31
|
+
bucket: string;
|
|
32
|
+
key: string;
|
|
33
|
+
etag: string;
|
|
34
|
+
eTag: string; // for backward compatibility
|
|
35
|
+
ETag: string; // for backward compatibility
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ListBucketResult {
|
|
39
|
+
keyCount: string;
|
|
40
|
+
contents?: Array<Record<string, unknown>>;
|
|
41
|
+
}
|
|
42
|
+
interface ListBucketError {
|
|
43
|
+
error: { code: string; message: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ListBucketResponse = { listBucketResult: ListBucketResult } | { error: ListBucketError };
|
|
47
|
+
|
|
48
|
+
export interface ListMultipartUploadSuccess {
|
|
49
|
+
listMultipartUploadsResult: {
|
|
50
|
+
bucket: string;
|
|
51
|
+
key: string;
|
|
52
|
+
uploadId: string;
|
|
53
|
+
size?: number;
|
|
54
|
+
mtime?: Date | undefined;
|
|
55
|
+
etag?: string;
|
|
56
|
+
eTag?: string; // for backward compatibility
|
|
57
|
+
parts: UploadPart[];
|
|
58
|
+
isTruncated: boolean;
|
|
59
|
+
uploads: UploadPart[];
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MultipartUploadError {
|
|
64
|
+
error: {
|
|
65
|
+
code: string;
|
|
66
|
+
message: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ErrorWithCode {
|
|
71
|
+
code?: string;
|
|
72
|
+
cause?: { code?: string };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ListMultipartUploadResponse = ListMultipartUploadSuccess | MultipartUploadError;
|
|
76
|
+
|
|
77
|
+
export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE';
|
|
78
|
+
|
|
79
|
+
// false - Not found (404)
|
|
80
|
+
// true - Found (200)
|
|
81
|
+
// null - ETag mismatch (412)
|
|
82
|
+
export type ExistResponseCode = false | true | null;
|
|
83
|
+
|
|
84
|
+
export type XmlValue = string | XmlMap | boolean | number | null;
|
|
85
|
+
export interface XmlMap {
|
|
86
|
+
[key: string]: XmlValue | XmlValue[]; // one or many children
|
|
87
|
+
[key: number]: XmlValue | XmlValue[]; // allow numeric keys
|
|
88
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Crypto, XmlValue, XmlMap, ListBucketResponse, ErrorWithCode } from './types.js';
|
|
4
|
+
declare const crypto: Crypto;
|
|
5
|
+
|
|
6
|
+
// Initialize crypto functions
|
|
7
|
+
const _createHmac: Crypto['createHmac'] = crypto.createHmac || (await import('node:crypto')).createHmac;
|
|
8
|
+
const _createHash: Crypto['createHash'] = crypto.createHash || (await import('node:crypto')).createHash;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hash content using SHA-256
|
|
12
|
+
* @param {string|Buffer} content – data to hash
|
|
13
|
+
* @returns {string} Hex encoded hash
|
|
14
|
+
*/
|
|
15
|
+
export const hash = (content: string | Buffer): string => {
|
|
16
|
+
return _createHash('sha256').update(content).digest('hex');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute HMAC-SHA-256 of arbitrary data and return a hex string.
|
|
21
|
+
* @param {string|Buffer} key – secret key
|
|
22
|
+
* @param {string|Buffer} content – data to authenticate
|
|
23
|
+
* @param {BufferEncoding} [encoding='hex'] – hex | base64 | …
|
|
24
|
+
* @returns {string | Buffer} hex encoded HMAC
|
|
25
|
+
*/
|
|
26
|
+
export const hmac = (key: string | Buffer, content: string | Buffer, encoding?: 'hex' | 'base64'): string | Buffer => {
|
|
27
|
+
const mac = _createHmac('sha256', key).update(content);
|
|
28
|
+
return encoding ? mac.digest(encoding) : mac.digest();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize ETag value by removing quotes and XML entities
|
|
33
|
+
* @param etag ETag value to sanitize
|
|
34
|
+
* @returns Sanitized ETag
|
|
35
|
+
*/
|
|
36
|
+
export const sanitizeETag = (etag: string): string => {
|
|
37
|
+
const replaceChars: Record<string, string> = {
|
|
38
|
+
'"': '',
|
|
39
|
+
'"': '',
|
|
40
|
+
'"': '',
|
|
41
|
+
'"': '',
|
|
42
|
+
'"': '',
|
|
43
|
+
};
|
|
44
|
+
return etag.replace(/^("|"|")|("|"|")$/g, m => replaceChars[m] as string);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const entityMap = {
|
|
48
|
+
'"': '"',
|
|
49
|
+
''': "'",
|
|
50
|
+
'<': '<',
|
|
51
|
+
'>': '>',
|
|
52
|
+
'&': '&',
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
const unescapeXml = (value: string): string =>
|
|
56
|
+
value.replace(/&(quot|apos|lt|gt|amp);/g, m => entityMap[m as keyof typeof entityMap] ?? m);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a very small subset of XML into a JS structure.
|
|
60
|
+
*
|
|
61
|
+
* @param input raw XML string
|
|
62
|
+
* @returns string for leaf nodes, otherwise a map of children
|
|
63
|
+
*/
|
|
64
|
+
export const parseXml = (input: string): XmlValue => {
|
|
65
|
+
const RE_TAG = /<(\w)([-\w]+)(?:\/|[^>]*>((?:(?!<\1)[\s\S])*)<\/\1\2)>/gm;
|
|
66
|
+
const result: XmlMap = {}; // strong type, no `any`
|
|
67
|
+
let match: RegExpExecArray | null;
|
|
68
|
+
|
|
69
|
+
while ((match = RE_TAG.exec(input)) !== null) {
|
|
70
|
+
const [, prefix = '', key, inner] = match;
|
|
71
|
+
const fullKey = `${prefix.toLowerCase()}${key}`;
|
|
72
|
+
const node: XmlValue = inner ? parseXml(inner) : '';
|
|
73
|
+
|
|
74
|
+
const current = result[fullKey];
|
|
75
|
+
if (current === undefined) {
|
|
76
|
+
// first occurrence
|
|
77
|
+
result[fullKey] = node;
|
|
78
|
+
} else if (Array.isArray(current)) {
|
|
79
|
+
// already an array
|
|
80
|
+
current.push(node);
|
|
81
|
+
} else {
|
|
82
|
+
// promote to array on the second occurrence
|
|
83
|
+
result[fullKey] = [current, node];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// No child tags? — return the text, after entity decode
|
|
88
|
+
return Object.keys(result).length > 0 ? result : unescapeXml(input);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Encode a character as a URI percent-encoded hex value
|
|
93
|
+
* @param c Character to encode
|
|
94
|
+
* @returns Percent-encoded character
|
|
95
|
+
*/
|
|
96
|
+
const encodeAsHex = (c: string): string => `%${c.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Escape a URI string using percent encoding
|
|
100
|
+
* @param uriStr URI string to escape
|
|
101
|
+
* @returns Escaped URI string
|
|
102
|
+
*/
|
|
103
|
+
export const uriEscape = (uriStr: string): string => {
|
|
104
|
+
return encodeURIComponent(uriStr).replace(/[!'()*]/g, encodeAsHex);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Escape a URI resource path while preserving forward slashes
|
|
109
|
+
* @param string URI path to escape
|
|
110
|
+
* @returns Escaped URI path
|
|
111
|
+
*/
|
|
112
|
+
export const uriResourceEscape = (string: string): string => {
|
|
113
|
+
return uriEscape(string).replace(/%2F/g, '/');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const isListBucketResponse = (value: unknown): value is ListBucketResponse => {
|
|
117
|
+
return typeof value === 'object' && value !== null && ('listBucketResult' in value || 'error' in value);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const extractErrCode = (e: unknown): string | undefined => {
|
|
121
|
+
if (typeof e !== 'object' || e === null) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const err = e as ErrorWithCode;
|
|
125
|
+
if (typeof err.code === 'string') {
|
|
126
|
+
return err.code;
|
|
127
|
+
}
|
|
128
|
+
return typeof err.cause?.code === 'string' ? err.cause.code : undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export class S3Error extends Error {
|
|
132
|
+
readonly code?: string;
|
|
133
|
+
constructor(msg: string, code?: string, cause?: unknown) {
|
|
134
|
+
super(msg);
|
|
135
|
+
this.name = new.target.name; // keeps instanceof usable
|
|
136
|
+
this.code = code;
|
|
137
|
+
this.cause = cause;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class S3NetworkError extends S3Error {}
|
|
142
|
+
export class S3ServiceError extends S3Error {
|
|
143
|
+
readonly status: number;
|
|
144
|
+
readonly serviceCode?: string;
|
|
145
|
+
body: string | undefined;
|
|
146
|
+
constructor(msg: string, status: number, serviceCode?: string, body?: string) {
|
|
147
|
+
super(msg, serviceCode);
|
|
148
|
+
this.status = status;
|
|
149
|
+
this.serviceCode = serviceCode;
|
|
150
|
+
this.body = body;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run async-returning tasks in batches with an *optional* minimum
|
|
156
|
+
* spacing (minIntervalMs) between the *start* times of successive batches.
|
|
157
|
+
*
|
|
158
|
+
* @param {Iterable<() => Promise<unknonw>>} tasks – functions returning Promises
|
|
159
|
+
* @param {number} [batchSize=30] – max concurrent requests
|
|
160
|
+
* @param {number} [minIntervalMs=0] – ≥0; 0 means “no pacing”
|
|
161
|
+
* @returns {Promise<Array<PromiseSettledResult<unknonw>>>}
|
|
162
|
+
*/
|
|
163
|
+
export const runInBatches = async (
|
|
164
|
+
tasks: Iterable<() => Promise<unknown>>,
|
|
165
|
+
batchSize = 30,
|
|
166
|
+
minIntervalMs = 0,
|
|
167
|
+
): Promise<Array<PromiseSettledResult<unknown>>> => {
|
|
168
|
+
const allResults: PromiseSettledResult<unknown>[] = [];
|
|
169
|
+
let batch: Array<() => Promise<unknown>> = [];
|
|
170
|
+
|
|
171
|
+
for (const task of tasks) {
|
|
172
|
+
batch.push(task);
|
|
173
|
+
if (batch.length === batchSize) {
|
|
174
|
+
await executeBatch(batch);
|
|
175
|
+
batch = [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (batch.length) {
|
|
179
|
+
await executeBatch(batch);
|
|
180
|
+
}
|
|
181
|
+
return allResults;
|
|
182
|
+
|
|
183
|
+
// ───────── helpers ──────────
|
|
184
|
+
async function executeBatch(batchFns: Array<() => Promise<unknown>>): Promise<void> {
|
|
185
|
+
const start = Date.now();
|
|
186
|
+
|
|
187
|
+
const settled = await Promise.allSettled(batchFns.map(fn => fn()));
|
|
188
|
+
allResults.push(...settled);
|
|
189
|
+
|
|
190
|
+
if (minIntervalMs > 0) {
|
|
191
|
+
const wait = minIntervalMs - (Date.now() - start);
|
|
192
|
+
if (wait > 0) {
|
|
193
|
+
await new Promise(r => setTimeout(r, wait));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|