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/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
+ '&quot;': '',
40
+ '&#34;': '',
41
+ '&QUOT;': '',
42
+ '&#x00022': '',
43
+ };
44
+ return etag.replace(/^("|&quot;|&#34;)|("|&quot;|&#34;)$/g, m => replaceChars[m] as string);
45
+ };
46
+
47
+ const entityMap = {
48
+ '&quot;': '"',
49
+ '&apos;': "'",
50
+ '&lt;': '<',
51
+ '&gt;': '>',
52
+ '&amp;': '&',
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
+ };