12 Commits

Author SHA1 Message Date
0d4837184f v3.1.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-23 22:37:32 +00:00
7f3de92961 feat(logging): Add structured Logger and integrate into Smarts3Server; pass full config to server 2025-11-23 22:37:32 +00:00
a7bc902dd0 v3.0.4
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-23 22:31:44 +00:00
95d78d0d08 fix(smarts3): Use filesystem store for bucket creation and remove smartbucket runtime dependency 2025-11-23 22:31:44 +00:00
b62cb0bc97 v3.0.3
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-23 22:12:29 +00:00
32346636e0 fix(filesystem): Migrate filesystem implementation to @push.rocks/smartfs and add Web Streams handling 2025-11-23 22:12:29 +00:00
415ba3e76d v3.0.2
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-21 18:36:27 +00:00
6594f67d3e fix(smarts3): Prepare patch release 3.0.2 — no code changes detected 2025-11-21 18:36:27 +00:00
61974e0b54 v3.0.1
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 46s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-21 17:09:16 +00:00
fc845956fa fix(readme): Add Issue Reporting and Security section to README 2025-11-21 17:09:16 +00:00
eec1e09d2b v3.0.0
Some checks failed
Default (tags) / security (push) Successful in 25s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-21 14:36:30 +00:00
c3daf9d3f7 BREAKING CHANGE(Smarts3): Remove legacy s3rver backend, simplify Smarts3 server API, and bump dependencies 2025-11-21 14:36:30 +00:00
13 changed files with 1481 additions and 277 deletions

View File

@@ -1,5 +1,51 @@
# Changelog # Changelog
## 2025-11-23 - 3.1.0 - feat(logging)
Add structured Logger and integrate into Smarts3Server; pass full config to server
- Introduce a new Logger class (ts/classes/logger.ts) providing leveled logging (error, warn, info, debug), text/json formats and an enable flag.
- Integrate Logger into Smarts3Server: use structured logging for server lifecycle events, HTTP request/response logging and S3 errors instead of direct console usage.
- Smarts3 now passes the full merged configuration into Smarts3Server (config.logging can control logging behavior).
- Server start/stop messages and internal request/error logs are emitted via the Logger and respect the configured logging level/format and silent option.
## 2025-11-23 - 3.0.4 - fix(smarts3)
Use filesystem store for bucket creation and remove smartbucket runtime dependency
- Switched createBucket to call the internal FilesystemStore.createBucket instead of using @push.rocks/smartbucket
- Made Smarts3Server.store public so Smarts3 can access the filesystem store directly
- Removed runtime import/export of @push.rocks/smartbucket from plugins and moved @push.rocks/smartbucket to devDependencies in package.json
- Updated createBucket to return a simple { name } object after creating the bucket via the filesystem store
## 2025-11-23 - 3.0.3 - fix(filesystem)
Migrate filesystem implementation to @push.rocks/smartfs and add Web Streams handling
- Replace dependency @push.rocks/smartfile with @push.rocks/smartfs and update README references
- plugins: instantiate SmartFs with SmartFsProviderNode and export smartfs (remove direct fs export)
- Refactor FilesystemStore to use smartfs directory/file APIs for initialize, reset, list, read, write, copy and delete
- Implement Web Stream ↔ Node.js stream conversion for uploads/downloads (Readable.fromWeb and writer.write with Uint8Array)
- Persist and read metadata (.metadata.json) and cached MD5 (.md5) via smartfs APIs
- Update readme.hints and documentation to note successful migration and next steps
## 2025-11-21 - 3.0.2 - fix(smarts3)
Prepare patch release 3.0.2 — no code changes detected
- No source changes in the diff
- Bump patch version from 3.0.1 to 3.0.2 for maintenance/release bookkeeping
## 2025-11-21 - 3.0.1 - fix(readme)
Add Issue Reporting and Security section to README
- Add guidance to report bugs, issues, and security vulnerabilities via community.foss.global
- Inform developers how to sign a contribution agreement and get a code.foss.global account to submit pull requests
## 2025-11-21 - 3.0.0 - BREAKING CHANGE(Smarts3)
Remove legacy s3rver backend, simplify Smarts3 server API, and bump dependencies
- Remove legacy s3rver backend: s3rver and its types were removed from dependencies and are no longer exported from plugins.
- Simplify Smarts3 API: removed useCustomServer option; Smarts3 now always uses the built-in Smarts3Server (s3Instance is Smarts3Server) and stop() always calls Smarts3Server.stop().
- Update README to remove legacy s3rver compatibility mention.
- Dependency updates: bumped @push.rocks/smartbucket to ^4.3.0 and @push.rocks/smartxml to ^2.0.0 (major upgrades), removed s3rver/@types/s3rver, bumped @aws-sdk/client-s3 to ^3.937.0 and @git.zone/tstest to ^3.1.0.
## 2025-11-21 - 2.3.0 - feat(smarts3-server) ## 2025-11-21 - 2.3.0 - feat(smarts3-server)
Introduce native custom S3 server implementation (Smarts3Server) with routing, middleware, context, filesystem store, controllers and XML utilities; add SmartXml and AWS SDK test; keep optional legacy s3rver backend. Introduce native custom S3 server implementation (Smarts3Server) with routing, middleware, context, filesystem store, controllers and XML utilities; add SmartXml and AWS SDK test; keep optional legacy s3rver backend.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smarts3", "name": "@push.rocks/smarts3",
"version": "2.3.0", "version": "3.1.0",
"private": false, "private": false,
"description": "A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.", "description": "A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -14,11 +14,12 @@
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.936.0", "@aws-sdk/client-s3": "^3.937.0",
"@git.zone/tsbuild": "^3.1.0", "@git.zone/tsbuild": "^3.1.0",
"@git.zone/tsbundle": "^2.5.2", "@git.zone/tsbundle": "^2.5.2",
"@git.zone/tsrun": "^2.0.0", "@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.0.0", "@git.zone/tstest": "^3.1.0",
"@push.rocks/smartbucket": "^4.3.0",
"@types/node": "^22.9.0" "@types/node": "^22.9.0"
}, },
"browserslist": [ "browserslist": [
@@ -37,13 +38,10 @@
"readme.md" "readme.md"
], ],
"dependencies": { "dependencies": {
"@push.rocks/smartbucket": "^3.3.10", "@push.rocks/smartfs": "^1.1.0",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartxml": "^1.0.6", "@push.rocks/smartxml": "^2.0.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0"
"@types/s3rver": "^3.7.0",
"s3rver": "^3.7.1"
}, },
"keywords": [ "keywords": [
"S3 Mock Server", "S3 Mock Server",

156
pnpm-lock.yaml generated
View File

@@ -8,31 +8,22 @@ importers:
.: .:
dependencies: dependencies:
'@push.rocks/smartbucket': '@push.rocks/smartfs':
specifier: ^3.3.10 specifier: ^1.1.0
version: 3.3.10 version: 1.1.0
'@push.rocks/smartfile':
specifier: ^11.2.7
version: 11.2.7
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
'@push.rocks/smartxml': '@push.rocks/smartxml':
specifier: ^1.0.6 specifier: ^2.0.0
version: 1.1.1 version: 2.0.0
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
'@types/s3rver':
specifier: ^3.7.0
version: 3.7.4
s3rver:
specifier: ^3.7.1
version: 3.7.1
devDependencies: devDependencies:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.936.0 specifier: ^3.937.0
version: 3.936.0 version: 3.937.0
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0 version: 3.1.0
@@ -43,8 +34,11 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@git.zone/tstest': '@git.zone/tstest':
specifier: ^3.0.0 specifier: ^3.1.0
version: 3.0.1(socks@2.8.7)(typescript@5.9.3) version: 3.1.0(socks@2.8.7)(typescript@5.9.3)
'@push.rocks/smartbucket':
specifier: ^4.3.0
version: 4.3.0
'@types/node': '@types/node':
specifier: ^22.9.0 specifier: ^22.9.0
version: 22.19.1 version: 22.19.1
@@ -89,8 +83,8 @@ packages:
'@aws-crypto/util@5.2.0': '@aws-crypto/util@5.2.0':
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
'@aws-sdk/client-s3@3.936.0': '@aws-sdk/client-s3@3.937.0':
resolution: {integrity: sha512-dnzZAkJDa9tdCxhqdnh37hdizJkernoFn0rufWahziOEmf0Yv9+mLeqR4qDmsAGUMuD1jFCmPR97FaCoh10mZg==} resolution: {integrity: sha512-ioeNe6HSc7PxjsUQY7foSHmgesxM5KwAeUtPhIHgKx99nrM+7xYCfW4FMvHypUzz7ZOvqlCdH7CEAZ8ParBvVg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@aws-sdk/client-sso@3.936.0': '@aws-sdk/client-sso@3.936.0':
@@ -240,8 +234,8 @@ packages:
'@borewit/text-codec@0.1.1': '@borewit/text-codec@0.1.1':
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
'@cloudflare/workers-types@4.20251120.0': '@cloudflare/workers-types@4.20251121.0':
resolution: {integrity: sha512-/uy0Oleot60ZS037I2mxR9NEft6eQYdknKBnM76W91I+7BKznzXKj2MtXMfSXTLsxyP+6MluYRNPrRCQDlk8kw==} resolution: {integrity: sha512-jzFg7hEGKzpEalxTCanN6lM8IdkvO/brsERp/+OyMms4Zi0nhDPUAg9dUcKU8wDuDUnzbjkplY6YRwle7Cq6gA==}
'@colors/colors@1.6.0': '@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
@@ -443,8 +437,8 @@ packages:
resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==} resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==}
hasBin: true hasBin: true
'@git.zone/tstest@3.0.1': '@git.zone/tstest@3.1.0':
resolution: {integrity: sha512-YjjLLWGj8fE8yYAfMrLSDgdZ+JJOS7I6iRshIyr6THH5dnTONOA3R076zBaryRw58qgPn+s/0jno7wlhYhv0iw==} resolution: {integrity: sha512-nshpkFvyIUUDvYcA/IOyqWBVEoxGm674ytIkA+XJ6DPO/hz2l3mMIjplc43d2U2eHkAZk8/ycr9GIo0xNhiLFg==}
hasBin: true hasBin: true
'@happy-dom/global-registrator@15.11.7': '@happy-dom/global-registrator@15.11.7':
@@ -503,22 +497,6 @@ packages:
'@napi-rs/wasm-runtime@1.0.7': '@napi-rs/wasm-runtime@1.0.7':
resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
'@oozcitak/dom@1.15.10':
resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==}
engines: {node: '>=8.0'}
'@oozcitak/infra@1.0.8':
resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==}
engines: {node: '>=6.0'}
'@oozcitak/url@1.0.4':
resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==}
engines: {node: '>=8.0'}
'@oozcitak/util@8.3.8':
resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==}
engines: {node: '>=8.0'}
'@oxc-project/types@0.98.0': '@oxc-project/types@0.98.0':
resolution: {integrity: sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw==} resolution: {integrity: sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw==}
@@ -612,6 +590,9 @@ packages:
'@push.rocks/smartbucket@3.3.10': '@push.rocks/smartbucket@3.3.10':
resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==} resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==}
'@push.rocks/smartbucket@4.3.0':
resolution: {integrity: sha512-4nstzEduCKou4R5ekKH6kUjDZXWfrtjA1hIQ4MJmTbtncmm2+4+ixjaFThS2nS8Aa+fHcBgOtKkBv8wTsgvK/Q==}
'@push.rocks/smartbuffer@3.0.5': '@push.rocks/smartbuffer@3.0.5':
resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==} resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==}
@@ -663,6 +644,9 @@ packages:
'@push.rocks/smartfile@11.2.7': '@push.rocks/smartfile@11.2.7':
resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==} resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==}
'@push.rocks/smartfs@1.1.0':
resolution: {integrity: sha512-fg8JIjFUPPX5laRoBpTaGwhMfZ3Y8mFT4fUaW54Y4J/BfOBa/y0+rIFgvgvqcOZgkQlyZU+FIfL8Z6zezqxyTg==}
'@push.rocks/smartguard@3.1.0': '@push.rocks/smartguard@3.1.0':
resolution: {integrity: sha512-J23q84f1O+TwFGmd4lrO9XLHUh2DaLXo9PN/9VmTWYzTkQDv5JehmifXVI0esophXcCIfbdIu6hbt7/aHlDF4A==} resolution: {integrity: sha512-J23q84f1O+TwFGmd4lrO9XLHUh2DaLXo9PN/9VmTWYzTkQDv5JehmifXVI0esophXcCIfbdIu6hbt7/aHlDF4A==}
@@ -783,9 +767,6 @@ packages:
'@push.rocks/smartversion@3.0.5': '@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartxml@1.1.1':
resolution: {integrity: sha512-1toSmLE1EGK8oENh09XjV588+IdzUB3x1PCaxKjSyIsAt54bUQj3kH/yzLODF+19p07OE0KM5U1oqWpjOcFCzA==}
'@push.rocks/smartxml@2.0.0': '@push.rocks/smartxml@2.0.0':
resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==} resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==}
@@ -2248,6 +2229,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -2377,10 +2362,6 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
js-yaml@3.14.2: js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true hasBin: true
@@ -3572,10 +3553,6 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xmlbuilder2@3.1.1:
resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==}
engines: {node: '>=12.0'}
xmlhttprequest-ssl@2.1.2: xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -3641,7 +3618,7 @@ snapshots:
'@api.global/typedrequest': 3.1.10 '@api.global/typedrequest': 3.1.10
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 3.0.1 '@api.global/typedsocket': 3.0.1
'@cloudflare/workers-types': 4.20251120.0 '@cloudflare/workers-types': 4.20251121.0
'@design.estate/dees-comms': 1.0.27 '@design.estate/dees-comms': 1.0.27
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.1.1 '@push.rocks/smartchok': 1.1.1
@@ -3748,7 +3725,7 @@ snapshots:
'@smithy/util-utf8': 2.3.0 '@smithy/util-utf8': 2.3.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-s3@3.936.0': '@aws-sdk/client-s3@3.937.0':
dependencies: dependencies:
'@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha1-browser': 5.2.0
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0
@@ -4192,7 +4169,7 @@ snapshots:
'@borewit/text-codec@0.1.1': {} '@borewit/text-codec@0.1.1': {}
'@cloudflare/workers-types@4.20251120.0': {} '@cloudflare/workers-types@4.20251121.0': {}
'@colors/colors@1.6.0': {} '@colors/colors@1.6.0': {}
@@ -4412,7 +4389,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
tsx: 4.20.6 tsx: 4.20.6
'@git.zone/tstest@3.0.1(socks@2.8.7)(typescript@5.9.3)': '@git.zone/tstest@3.1.0(socks@2.8.7)(typescript@5.9.3)':
dependencies: dependencies:
'@api.global/typedserver': 3.0.80 '@api.global/typedserver': 3.0.80
'@git.zone/tsbundle': 2.5.2 '@git.zone/tsbundle': 2.5.2
@@ -4535,23 +4512,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@oozcitak/dom@1.15.10':
dependencies:
'@oozcitak/infra': 1.0.8
'@oozcitak/url': 1.0.4
'@oozcitak/util': 8.3.8
'@oozcitak/infra@1.0.8':
dependencies:
'@oozcitak/util': 8.3.8
'@oozcitak/url@1.0.4':
dependencies:
'@oozcitak/infra': 1.0.8
'@oozcitak/util': 8.3.8
'@oozcitak/util@8.3.8': {}
'@oxc-project/types@0.98.0': {} '@oxc-project/types@0.98.0': {}
'@pdf-lib/standard-fonts@1.0.0': '@pdf-lib/standard-fonts@1.0.0':
@@ -4792,7 +4752,7 @@ snapshots:
'@push.rocks/smartbucket@3.3.10': '@push.rocks/smartbucket@3.3.10':
dependencies: dependencies:
'@aws-sdk/client-s3': 3.936.0 '@aws-sdk/client-s3': 3.937.0
'@push.rocks/smartmime': 2.0.4 '@push.rocks/smartmime': 2.0.4
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
@@ -4804,6 +4764,21 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@push.rocks/smartbucket@4.3.0':
dependencies:
'@aws-sdk/client-s3': 3.937.0
'@push.rocks/smartmime': 2.0.4
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstream': 3.2.5
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0
minimatch: 10.1.1
transitivePeerDependencies:
- aws-crt
'@push.rocks/smartbuffer@3.0.5': '@push.rocks/smartbuffer@3.0.5':
dependencies: dependencies:
uint8array-extras: 1.5.0 uint8array-extras: 1.5.0
@@ -4948,6 +4923,10 @@ snapshots:
glob: 11.1.0 glob: 11.1.0
js-yaml: 4.1.1 js-yaml: 4.1.1
'@push.rocks/smartfs@1.1.0':
dependencies:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartguard@3.1.0': '@push.rocks/smartguard@3.1.0':
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
@@ -5292,11 +5271,6 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.3 semver: 7.7.3
'@push.rocks/smartxml@1.1.1':
dependencies:
fast-xml-parser: 4.5.3
xmlbuilder2: 3.1.1
'@push.rocks/smartxml@2.0.0': '@push.rocks/smartxml@2.0.0':
dependencies: dependencies:
fast-xml-parser: 5.3.2 fast-xml-parser: 5.3.2
@@ -6183,7 +6157,7 @@ snapshots:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 content-type: 1.0.5
debug: 4.4.3 debug: 4.4.3
http-errors: 2.0.0 http-errors: 2.0.1
iconv-lite: 0.6.3 iconv-lite: 0.6.3
on-finished: 2.4.1 on-finished: 2.4.1
qs: 6.14.0 qs: 6.14.0
@@ -6641,7 +6615,7 @@ snapshots:
etag: 1.8.1 etag: 1.8.1
finalhandler: 2.1.0 finalhandler: 2.1.0
fresh: 2.0.0 fresh: 2.0.0
http-errors: 2.0.0 http-errors: 2.0.1
merge-descriptors: 2.0.0 merge-descriptors: 2.0.0
mime-types: 3.0.2 mime-types: 3.0.2
on-finished: 2.4.1 on-finished: 2.4.1
@@ -6981,6 +6955,14 @@ snapshots:
statuses: 2.0.1 statuses: 2.0.1
toidentifier: 1.0.1 toidentifier: 1.0.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
@@ -7094,11 +7076,6 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@3.14.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@3.14.2: js-yaml@3.14.2:
dependencies: dependencies:
argparse: 1.0.10 argparse: 1.0.10
@@ -8138,7 +8115,7 @@ snapshots:
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
fresh: 2.0.0 fresh: 2.0.0
http-errors: 2.0.0 http-errors: 2.0.1
mime-types: 3.0.2 mime-types: 3.0.2
ms: 2.1.3 ms: 2.1.3
on-finished: 2.4.1 on-finished: 2.4.1
@@ -8596,13 +8573,6 @@ snapshots:
ws@8.18.3: {} ws@8.18.3: {}
xmlbuilder2@3.1.1:
dependencies:
'@oozcitak/dom': 1.15.10
'@oozcitak/infra': 1.0.8
'@oozcitak/util': 8.3.8
js-yaml: 3.14.1
xmlhttprequest-ssl@2.1.2: {} xmlhttprequest-ssl@2.1.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}

438
production-readiness.md Normal file
View File

@@ -0,0 +1,438 @@
# Production-Readiness Plan for smarts3
**Goal:** Make smarts3 production-ready as a MinIO alternative for use cases where:
- Running MinIO is out of scope
- You have a program written for S3 and want to use the local filesystem
- You need a lightweight, zero-dependency S3-compatible server
---
## 🔍 Current State Analysis
### ✅ What's Working
- **Native S3 server** with zero framework dependencies
- **Core S3 operations:** PUT, GET, HEAD, DELETE (objects & buckets)
- **List buckets and objects** (V1 and V2 API)
- **Object copy** with metadata handling
- **Range requests** for partial downloads
- **MD5 checksums** and ETag support
- **Custom metadata** (x-amz-meta-*)
- **Filesystem-backed storage** with Windows compatibility
- **S3-compatible XML error responses**
- **Middleware system** and routing
- **AWS SDK v3 compatibility** (tested)
### ❌ Production Gaps Identified
---
## 🎯 Critical Features (Required for Production)
### 1. Multipart Upload Support 🚀 **HIGHEST PRIORITY**
**Why:** Essential for uploading files >5MB efficiently. Without this, smarts3 can't handle real-world production workloads.
**Implementation Required:**
- `POST /:bucket/:key?uploads` - CreateMultipartUpload
- `PUT /:bucket/:key?partNumber=X&uploadId=Y` - UploadPart
- `POST /:bucket/:key?uploadId=X` - CompleteMultipartUpload
- `DELETE /:bucket/:key?uploadId=X` - AbortMultipartUpload
- `GET /:bucket/:key?uploadId=X` - ListParts
- Multipart state management (temp storage for parts)
- Part ETag tracking and validation
- Automatic cleanup of abandoned uploads
**Files to Create/Modify:**
- `ts/controllers/multipart.controller.ts` (new)
- `ts/classes/filesystem-store.ts` (add multipart methods)
- `ts/classes/smarts3-server.ts` (add multipart routes)
---
### 2. Configurable Authentication 🔐
**Why:** Currently hardcoded credentials ('S3RVER'/'S3RVER'). Production needs custom credentials.
**Implementation Required:**
- Support custom access keys and secrets via configuration
- Implement AWS Signature V4 verification
- Support multiple credential pairs (IAM-like users)
- Optional: Disable authentication for local dev use
**Configuration Example:**
```typescript
interface IAuthConfig {
enabled: boolean;
credentials: Array<{
accessKeyId: string;
secretAccessKey: string;
}>;
signatureVersion: 'v4' | 'none';
}
```
**Files to Create/Modify:**
- `ts/classes/auth-middleware.ts` (new)
- `ts/classes/signature-validator.ts` (new)
- `ts/classes/smarts3-server.ts` (integrate auth middleware)
- `ts/index.ts` (add auth config options)
---
### 3. CORS Support 🌐
**Why:** Required for browser-based uploads and modern web apps.
**Implementation Required:**
- Add CORS middleware
- Support preflight OPTIONS requests
- Configurable CORS origins, methods, headers
- Per-bucket CORS configuration (optional)
**Configuration Example:**
```typescript
interface ICorsConfig {
enabled: boolean;
allowedOrigins: string[]; // ['*'] or ['https://example.com']
allowedMethods: string[]; // ['GET', 'POST', 'PUT', 'DELETE']
allowedHeaders: string[]; // ['*'] or specific headers
exposedHeaders: string[]; // ['ETag', 'x-amz-*']
maxAge: number; // 3600 (seconds)
allowCredentials: boolean;
}
```
**Files to Create/Modify:**
- `ts/classes/cors-middleware.ts` (new)
- `ts/classes/smarts3-server.ts` (integrate CORS middleware)
- `ts/index.ts` (add CORS config options)
---
### 4. SSL/TLS Support 🔒
**Why:** Production systems require encrypted connections.
**Implementation Required:**
- HTTPS server option with cert/key configuration
- Auto-redirect HTTP to HTTPS (optional)
- Support for self-signed certs in dev mode
**Configuration Example:**
```typescript
interface ISslConfig {
enabled: boolean;
cert: string; // Path to certificate file or cert content
key: string; // Path to key file or key content
ca?: string; // Optional CA cert
redirectHttp?: boolean; // Redirect HTTP to HTTPS
}
```
**Files to Create/Modify:**
- `ts/classes/smarts3-server.ts` (add HTTPS server creation)
- `ts/index.ts` (add SSL config options)
---
### 5. Production Configuration System ⚙️
**Why:** Production needs flexible configuration, not just constructor options.
**Implementation Required:**
- Support configuration file (JSON/YAML)
- Environment variable support
- Configuration validation
- Sensible production defaults
- Example configurations for common use cases
**Configuration File Example (`smarts3.config.json`):**
```json
{
"server": {
"port": 3000,
"address": "0.0.0.0",
"ssl": {
"enabled": true,
"cert": "./certs/server.crt",
"key": "./certs/server.key"
}
},
"storage": {
"directory": "./s3-data",
"cleanSlate": false
},
"auth": {
"enabled": true,
"credentials": [
{
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
]
},
"cors": {
"enabled": true,
"allowedOrigins": ["*"],
"allowedMethods": ["GET", "POST", "PUT", "DELETE", "HEAD"],
"allowedHeaders": ["*"]
},
"limits": {
"maxObjectSize": 5368709120,
"maxMetadataSize": 2048,
"requestTimeout": 300000
},
"logging": {
"level": "info",
"format": "json",
"accessLog": {
"enabled": true,
"path": "./logs/access.log"
},
"errorLog": {
"enabled": true,
"path": "./logs/error.log"
}
}
}
```
**Files to Create/Modify:**
- `ts/classes/config-loader.ts` (new)
- `ts/classes/config-validator.ts` (new)
- `ts/index.ts` (use config loader)
- Create example config files in root
---
### 6. Production Logging 📝
**Why:** Console logs aren't suitable for production monitoring.
**Implementation Required:**
- Structured logging (JSON format option)
- Log levels (ERROR, WARN, INFO, DEBUG)
- File rotation support
- Access logs (S3 standard format)
- Integration with logging library
**Files to Create/Modify:**
- `ts/classes/logger.ts` (new - use @push.rocks/smartlog?)
- `ts/classes/access-logger-middleware.ts` (new)
- `ts/classes/smarts3-server.ts` (replace console.log with logger)
- All controller files (use structured logging)
---
## 🔧 Important Features (Should Have)
### 7. Health Check & Metrics 💊
**Implementation Required:**
- `GET /_health` endpoint (non-S3, for monitoring)
- `GET /_metrics` endpoint (Prometheus format?)
- Server stats (requests/sec, storage used, uptime)
- Readiness/liveness probes for Kubernetes
**Files to Create/Modify:**
- `ts/controllers/health.controller.ts` (new)
- `ts/classes/metrics-collector.ts` (new)
- `ts/classes/smarts3-server.ts` (add health routes)
---
### 8. Batch Operations 📦
**Implementation Required:**
- `POST /:bucket?delete` - DeleteObjects (delete multiple objects in one request)
- Essential for efficient cleanup operations
**Files to Create/Modify:**
- `ts/controllers/object.controller.ts` (add deleteObjects method)
---
### 9. Request Size Limits & Validation 🛡️
**Implementation Required:**
- Max object size configuration
- Max metadata size limits
- Request timeout configuration
- Body size limits
- Bucket name validation (S3 rules)
- Key name validation
**Files to Create/Modify:**
- `ts/classes/validation-middleware.ts` (new)
- `ts/utils/validators.ts` (new)
- `ts/classes/smarts3-server.ts` (integrate validation middleware)
---
### 10. Conditional Requests 🔄
**Implementation Required:**
- If-Match / If-None-Match (ETag validation)
- If-Modified-Since / If-Unmodified-Since
- Required for caching and conflict prevention
**Files to Create/Modify:**
- `ts/controllers/object.controller.ts` (add conditional logic to GET/HEAD)
---
### 11. Graceful Shutdown 👋
**Implementation Required:**
- Drain existing connections
- Reject new connections
- Clean multipart cleanup on shutdown
- SIGTERM/SIGINT handling
**Files to Create/Modify:**
- `ts/classes/smarts3-server.ts` (add graceful shutdown logic)
- `ts/index.ts` (add signal handlers)
---
## 💡 Nice-to-Have Features
### 12. Advanced Features
- Bucket versioning support
- Object tagging
- Lifecycle policies (auto-delete old objects)
- Storage class simulation (STANDARD, GLACIER, etc.)
- Server-side encryption simulation
- Presigned URL support (for time-limited access)
### 13. Performance Optimizations
- Stream optimization for large files
- Optional in-memory caching for small objects
- Parallel upload/download support
- Compression support (gzip)
### 14. Developer Experience
- Docker image for easy deployment
- Docker Compose examples
- Kubernetes manifests
- CLI for server management
- Admin API for bucket management
---
## 📐 Implementation Phases
### Phase 1: Critical Production Features (Priority 1)
**Estimated Effort:** 2-3 weeks
1. ✅ Multipart uploads (biggest technical lift)
2. ✅ Configurable authentication
3. ✅ CORS middleware
4. ✅ Production configuration system
5. ✅ Production logging
**Outcome:** smarts3 can handle real production workloads
---
### Phase 2: Reliability & Operations (Priority 2)
**Estimated Effort:** 1-2 weeks
6. ✅ SSL/TLS support
7. ✅ Health checks & metrics
8. ✅ Request validation & limits
9. ✅ Graceful shutdown
10. ✅ Batch operations
**Outcome:** smarts3 is operationally mature
---
### Phase 3: S3 Compatibility (Priority 3)
**Estimated Effort:** 1-2 weeks
11. ✅ Conditional requests
12. ✅ Additional S3 features as needed
13. ✅ Comprehensive test suite
14. ✅ Documentation updates
**Outcome:** smarts3 has broad S3 API compatibility
---
### Phase 4: Polish (Priority 4)
**Estimated Effort:** As needed
15. ✅ Docker packaging
16. ✅ Performance optimization
17. ✅ Advanced features based on user feedback
**Outcome:** smarts3 is a complete MinIO alternative
---
## 🤔 Open Questions
1. **Authentication:** Do you want full AWS Signature V4 validation, or simpler static credential checking?
2. **Configuration:** Prefer JSON, YAML, or .env file format?
3. **Logging:** Do you have a preferred logging library, or shall I use @push.rocks/smartlog?
4. **Scope:** Should we tackle all of Phase 1, or start with a subset (e.g., just multipart + auth)?
5. **Testing:** Should we add comprehensive tests as we go, or batch them at the end?
6. **Breaking changes:** Can I modify the constructor options interface, or must it remain backward compatible?
---
## 🎯 Target Use Cases
**With this plan implemented, smarts3 will be a solid MinIO alternative for:**
**Local S3 development** - Fast, simple, no Docker required
**Testing S3 integrations** - Reliable, repeatable tests
**Microservices using S3 API** with filesystem backend
**CI/CD pipelines** - Lightweight S3 for testing
**Small-to-medium production deployments** where MinIO is overkill
**Edge computing** - S3 API for local file storage
**Embedded systems** - Minimal dependencies, small footprint
---
## 📊 Current vs. Production Comparison
| Feature | Current | After Phase 1 | After Phase 2 | Production Ready |
|---------|---------|---------------|---------------|------------------|
| Basic S3 ops | ✅ | ✅ | ✅ | ✅ |
| Multipart upload | ❌ | ✅ | ✅ | ✅ |
| Authentication | ⚠️ (hardcoded) | ✅ | ✅ | ✅ |
| CORS | ❌ | ✅ | ✅ | ✅ |
| SSL/TLS | ❌ | ❌ | ✅ | ✅ |
| Config files | ❌ | ✅ | ✅ | ✅ |
| Production logging | ⚠️ (console) | ✅ | ✅ | ✅ |
| Health checks | ❌ | ❌ | ✅ | ✅ |
| Request limits | ❌ | ❌ | ✅ | ✅ |
| Graceful shutdown | ❌ | ❌ | ✅ | ✅ |
| Conditional requests | ❌ | ❌ | ❌ | ✅ |
| Batch operations | ❌ | ❌ | ✅ | ✅ |
---
## 📝 Notes
- All features should maintain backward compatibility where possible
- Each feature should include comprehensive tests
- Documentation (readme.md) should be updated as features are added
- Consider adding a migration guide for users upgrading from testing to production use
- Performance benchmarks should be established and maintained
---
**Last Updated:** 2025-11-23
**Status:** Planning Phase
**Next Step:** Get approval and prioritize implementation order

View File

@@ -1 +1,74 @@
# Project Hints for smarts3
## Current State (v3.0.0)
- Native custom S3 server implementation (Smarts3Server)
- No longer uses legacy s3rver backend (removed in v3.0.0)
- Core S3 operations working: PUT, GET, HEAD, DELETE for objects and buckets
- Multipart upload NOT yet implemented (critical gap for production)
- Authentication is hardcoded ('S3RVER'/'S3RVER') - not production-ready
- No CORS support yet
- No SSL/TLS support yet
## Production Readiness
See `production-readiness.md` for the complete gap analysis and implementation plan.
**Key Missing Features for Production:**
1. Multipart upload support (HIGHEST PRIORITY)
2. Configurable authentication
3. CORS middleware
4. SSL/TLS support
5. Production configuration system
6. Production logging
## Architecture Notes
### File Structure
- `ts/classes/smarts3-server.ts` - Main server class
- `ts/classes/filesystem-store.ts` - Storage layer (filesystem-backed)
- `ts/classes/router.ts` - URL routing with pattern matching
- `ts/classes/middleware-stack.ts` - Middleware execution
- `ts/classes/context.ts` - Request/response context
- `ts/classes/s3-error.ts` - S3-compatible error handling
- `ts/controllers/` - Service, bucket, and object controllers
- `ts/index.ts` - Main export (Smarts3 class)
### Storage Layout
- Objects stored as: `{bucket}/{encodedKey}._S3_object`
- Metadata stored as: `{bucket}/{encodedKey}._S3_object.metadata.json`
- MD5 stored as: `{bucket}/{encodedKey}._S3_object.md5`
- Keys are encoded for Windows compatibility (hex encoding for invalid chars)
### Current Limitations
- Max file size limited by available memory (no streaming multipart)
- Single server instance only (no clustering)
- No versioning support
- No access control beyond basic auth
## Testing
- Main test: `test/test.aws-sdk.node.ts` - Tests AWS SDK v3 compatibility
- Run with: `pnpm test`
- Tests run with cleanSlate mode enabled
## Dependencies
- `@push.rocks/smartbucket` - S3 abstraction layer
- `@push.rocks/smartfs` - Modern filesystem operations with Web Streams API (replaced smartfile)
- `@push.rocks/smartxml` - XML generation/parsing
- `@push.rocks/smartpath` - Path utilities
- `@tsclass/tsclass` - TypeScript utilities
## Migration Notes (2025-11-23)
Successfully migrated from `@push.rocks/smartfile` + native `fs` to `@push.rocks/smartfs`:
- All file/directory operations now use smartfs fluent API
- Web Streams → Node.js Streams conversion for HTTP compatibility
- All tests passing ✅
- Build successful ✅
## Next Steps
Waiting for approval to proceed with production-readiness implementation.
Priority 1 is implementing multipart uploads.

View File

@@ -5,14 +5,17 @@
## 🌟 Features ## 🌟 Features
- 🏃 **Lightning-fast local S3 simulation** - No more waiting for cloud operations during development - 🏃 **Lightning-fast local S3 simulation** - No more waiting for cloud operations during development
-**Native custom S3 server** - Built on Node.js http module with zero framework dependencies (default) -**Native custom S3 server** - Built on Node.js http module with zero framework dependencies
- 🔄 **Full AWS S3 API compatibility** - Drop-in replacement for AWS SDK v3 and other S3 clients - 🔄 **Full AWS S3 API compatibility** - Drop-in replacement for AWS SDK v3 and other S3 clients
- 📂 **Local directory mapping** - Your buckets live right on your filesystem with Windows-compatible encoding - 📂 **Local directory mapping** - Your buckets live right on your filesystem with Windows-compatible encoding
- 🧪 **Perfect for testing** - Reliable, repeatable tests without cloud dependencies - 🧪 **Perfect for testing** - Reliable, repeatable tests without cloud dependencies
- 🎯 **TypeScript-first** - Built with TypeScript for excellent type safety and IDE support - 🎯 **TypeScript-first** - Built with TypeScript for excellent type safety and IDE support
- 🔧 **Zero configuration** - Works out of the box with sensible defaults - 🔧 **Zero configuration** - Works out of the box with sensible defaults
- 🧹 **Clean slate mode** - Start fresh on every test run - 🧹 **Clean slate mode** - Start fresh on every test run
- 🔀 **Legacy compatibility** - Optional s3rver backend support for backward compatibility
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who want to sign a contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 📦 Installation ## 📦 Installation
@@ -410,9 +413,8 @@ interface ISmarts3ContructorOptions {
## 🔗 Related Packages ## 🔗 Related Packages
- [`@push.rocks/smartbucket`](https://www.npmjs.com/package/@push.rocks/smartbucket) - Powerful S3 abstraction layer - [`@push.rocks/smartbucket`](https://www.npmjs.com/package/@push.rocks/smartbucket) - Powerful S3 abstraction layer
- [`@push.rocks/smartfile`](https://www.npmjs.com/package/@push.rocks/smartfile) - Advanced file system operations - [`@push.rocks/smartfs`](https://www.npmjs.com/package/@push.rocks/smartfs) - Modern filesystem with Web Streams support
- [`@tsclass/tsclass`](https://www.npmjs.com/package/@tsclass/tsclass) - TypeScript class helpers - [`@tsclass/tsclass`](https://www.npmjs.com/package/@tsclass/tsclass) - TypeScript class helpers
- [`s3rver`](https://www.npmjs.com/package/s3rver) - Optional legacy S3 server implementation (used when `useCustomServer: false`)
## License and Legal Information ## License and Legal Information

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smarts3', name: '@push.rocks/smarts3',
version: '2.3.0', version: '3.1.0',
description: 'A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.' description: 'A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.'
} }

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { S3Error } from './s3-error.js'; import { S3Error } from './s3-error.js';
import type { Readable } from 'stream'; import { Readable } from 'stream';
export interface IS3Bucket { export interface IS3Bucket {
name: string; name: string;
@@ -39,7 +39,7 @@ export interface IRangeOptions {
} }
/** /**
* Filesystem-backed storage for S3 objects * Filesystem-backed storage for S3 objects using smartfs
*/ */
export class FilesystemStore { export class FilesystemStore {
constructor(private rootDir: string) {} constructor(private rootDir: string) {}
@@ -48,14 +48,19 @@ export class FilesystemStore {
* Initialize store (ensure root directory exists) * Initialize store (ensure root directory exists)
*/ */
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
await plugins.fs.promises.mkdir(this.rootDir, { recursive: true }); await plugins.smartfs.directory(this.rootDir).recursive().create();
} }
/** /**
* Reset store (delete all buckets) * Reset store (delete all buckets)
*/ */
public async reset(): Promise<void> { public async reset(): Promise<void> {
await plugins.smartfile.fs.ensureEmptyDir(this.rootDir); // Delete directory and recreate it
const exists = await plugins.smartfs.directory(this.rootDir).exists();
if (exists) {
await plugins.smartfs.directory(this.rootDir).recursive().delete();
}
await plugins.smartfs.directory(this.rootDir).recursive().create();
} }
// ============================ // ============================
@@ -66,17 +71,16 @@ export class FilesystemStore {
* List all buckets * List all buckets
*/ */
public async listBuckets(): Promise<IS3Bucket[]> { public async listBuckets(): Promise<IS3Bucket[]> {
const dirs = await plugins.smartfile.fs.listFolders(this.rootDir); const entries = await plugins.smartfs.directory(this.rootDir).includeStats().list();
const buckets: IS3Bucket[] = []; const buckets: IS3Bucket[] = [];
for (const dir of dirs) { for (const entry of entries) {
const bucketPath = plugins.path.join(this.rootDir, dir); if (entry.isDirectory && entry.stats) {
const stats = await plugins.smartfile.fs.stat(bucketPath); buckets.push({
name: entry.name,
buckets.push({ creationDate: entry.stats.birthtime,
name: dir, });
creationDate: stats.birthtime, }
});
} }
return buckets.sort((a, b) => a.name.localeCompare(b.name)); return buckets.sort((a, b) => a.name.localeCompare(b.name));
@@ -87,7 +91,7 @@ export class FilesystemStore {
*/ */
public async bucketExists(bucket: string): Promise<boolean> { public async bucketExists(bucket: string): Promise<boolean> {
const bucketPath = this.getBucketPath(bucket); const bucketPath = this.getBucketPath(bucket);
return plugins.smartfile.fs.isDirectory(bucketPath); return plugins.smartfs.directory(bucketPath).exists();
} }
/** /**
@@ -95,7 +99,7 @@ export class FilesystemStore {
*/ */
public async createBucket(bucket: string): Promise<void> { public async createBucket(bucket: string): Promise<void> {
const bucketPath = this.getBucketPath(bucket); const bucketPath = this.getBucketPath(bucket);
await plugins.fs.promises.mkdir(bucketPath, { recursive: true }); await plugins.smartfs.directory(bucketPath).recursive().create();
} }
/** /**
@@ -110,12 +114,12 @@ export class FilesystemStore {
} }
// Check if bucket is empty // Check if bucket is empty
const files = await plugins.smartfile.fs.listFileTree(bucketPath, '**/*'); const files = await plugins.smartfs.directory(bucketPath).recursive().list();
if (files.length > 0) { if (files.length > 0) {
throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty'); throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty');
} }
await plugins.smartfile.fs.remove(bucketPath); await plugins.smartfs.directory(bucketPath).recursive().delete();
} }
// ============================ // ============================
@@ -142,13 +146,16 @@ export class FilesystemStore {
continuationToken, continuationToken,
} = options; } = options;
// List all object files // List all object files recursively with filter
const objectPattern = '**/*._S3_object'; const entries = await plugins.smartfs
const objectFiles = await plugins.smartfile.fs.listFileTree(bucketPath, objectPattern); .directory(bucketPath)
.recursive()
.filter((entry) => entry.name.endsWith('._S3_object'))
.list();
// Convert file paths to keys // Convert file paths to keys
let keys = objectFiles.map((filePath) => { let keys = entries.map((entry) => {
const relativePath = plugins.path.relative(bucketPath, filePath); const relativePath = plugins.path.relative(bucketPath, entry.path);
const key = this.decodeKey(relativePath.replace(/\._S3_object$/, '')); const key = this.decodeKey(relativePath.replace(/\._S3_object$/, ''));
return key; return key;
}); });
@@ -226,7 +233,7 @@ export class FilesystemStore {
const md5Path = `${objectPath}.md5`; const md5Path = `${objectPath}.md5`;
const [stats, metadata, md5] = await Promise.all([ const [stats, metadata, md5] = await Promise.all([
plugins.smartfile.fs.stat(objectPath), plugins.smartfs.file(objectPath).stat(),
this.readMetadata(metadataPath), this.readMetadata(metadataPath),
this.readMD5(objectPath, md5Path), this.readMD5(objectPath, md5Path),
]); ]);
@@ -245,7 +252,7 @@ export class FilesystemStore {
*/ */
public async objectExists(bucket: string, key: string): Promise<boolean> { public async objectExists(bucket: string, key: string): Promise<boolean> {
const objectPath = this.getObjectPath(bucket, key); const objectPath = this.getObjectPath(bucket, key);
return plugins.smartfile.fs.fileExists(objectPath); return plugins.smartfs.file(objectPath).exists();
} }
/** /**
@@ -265,14 +272,15 @@ export class FilesystemStore {
} }
// Ensure parent directory exists // Ensure parent directory exists
await plugins.fs.promises.mkdir(plugins.path.dirname(objectPath), { recursive: true }); const parentDir = plugins.path.dirname(objectPath);
await plugins.smartfs.directory(parentDir).recursive().create();
// Write with MD5 calculation // Write with MD5 calculation
const result = await this.writeStreamWithMD5(stream, objectPath); const result = await this.writeStreamWithMD5(stream, objectPath);
// Save metadata // Save metadata
const metadataPath = `${objectPath}.metadata.json`; const metadataPath = `${objectPath}.metadata.json`;
await plugins.fs.promises.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); await plugins.smartfs.file(metadataPath).write(JSON.stringify(metadata, null, 2));
return result; return result;
} }
@@ -293,14 +301,50 @@ export class FilesystemStore {
const info = await this.getObjectInfo(bucket, key); const info = await this.getObjectInfo(bucket, key);
// Create read stream with optional range (using native fs for range support) // Get Web ReadableStream from smartfs
const stream = range const webStream = await plugins.smartfs.file(objectPath).readStream();
? plugins.fs.createReadStream(objectPath, { start: range.start, end: range.end })
: plugins.fs.createReadStream(objectPath); // Convert Web Stream to Node.js Readable stream
let nodeStream = Readable.fromWeb(webStream as any);
// Handle range requests if needed
if (range) {
// For range requests, we need to skip bytes and limit output
let bytesRead = 0;
const rangeStart = range.start;
const rangeEnd = range.end;
nodeStream = nodeStream.pipe(new (require('stream').Transform)({
transform(chunk: Buffer, encoding, callback) {
const chunkStart = bytesRead;
const chunkEnd = bytesRead + chunk.length - 1;
bytesRead += chunk.length;
// Skip chunks before range
if (chunkEnd < rangeStart) {
callback();
return;
}
// Stop after range
if (chunkStart > rangeEnd) {
this.end();
callback();
return;
}
// Slice chunk to fit range
const sliceStart = Math.max(0, rangeStart - chunkStart);
const sliceEnd = Math.min(chunk.length, rangeEnd - chunkStart + 1);
callback(null, chunk.slice(sliceStart, sliceEnd));
}
}));
}
return { return {
...info, ...info,
content: stream, content: nodeStream,
}; };
} }
@@ -314,9 +358,9 @@ export class FilesystemStore {
// S3 doesn't throw error if object doesn't exist // S3 doesn't throw error if object doesn't exist
await Promise.all([ await Promise.all([
plugins.smartfile.fs.remove(objectPath).catch(() => {}), plugins.smartfs.file(objectPath).delete().catch(() => {}),
plugins.smartfile.fs.remove(metadataPath).catch(() => {}), plugins.smartfs.file(metadataPath).delete().catch(() => {}),
plugins.smartfile.fs.remove(md5Path).catch(() => {}), plugins.smartfs.file(md5Path).delete().catch(() => {}),
]); ]);
} }
@@ -345,30 +389,31 @@ export class FilesystemStore {
} }
// Ensure parent directory exists // Ensure parent directory exists
await plugins.fs.promises.mkdir(plugins.path.dirname(destObjectPath), { recursive: true }); const parentDir = plugins.path.dirname(destObjectPath);
await plugins.smartfs.directory(parentDir).recursive().create();
// Copy object file // Copy object file
await plugins.smartfile.fs.copy(srcObjectPath, destObjectPath); await plugins.smartfs.file(srcObjectPath).copy(destObjectPath);
// Handle metadata // Handle metadata
if (metadataDirective === 'COPY') { if (metadataDirective === 'COPY') {
// Copy metadata // Copy metadata
const srcMetadataPath = `${srcObjectPath}.metadata.json`; const srcMetadataPath = `${srcObjectPath}.metadata.json`;
const destMetadataPath = `${destObjectPath}.metadata.json`; const destMetadataPath = `${destObjectPath}.metadata.json`;
await plugins.smartfile.fs.copy(srcMetadataPath, destMetadataPath).catch(() => {}); await plugins.smartfs.file(srcMetadataPath).copy(destMetadataPath).catch(() => {});
} else if (newMetadata) { } else if (newMetadata) {
// Replace with new metadata // Replace with new metadata
const destMetadataPath = `${destObjectPath}.metadata.json`; const destMetadataPath = `${destObjectPath}.metadata.json`;
await plugins.fs.promises.writeFile(destMetadataPath, JSON.stringify(newMetadata, null, 2)); await plugins.smartfs.file(destMetadataPath).write(JSON.stringify(newMetadata, null, 2));
} }
// Copy MD5 // Copy MD5
const srcMD5Path = `${srcObjectPath}.md5`; const srcMD5Path = `${srcObjectPath}.md5`;
const destMD5Path = `${destObjectPath}.md5`; const destMD5Path = `${destObjectPath}.md5`;
await plugins.smartfile.fs.copy(srcMD5Path, destMD5Path).catch(() => {}); await plugins.smartfs.file(srcMD5Path).copy(destMD5Path).catch(() => {});
// Get result info // Get result info
const stats = await plugins.smartfile.fs.stat(destObjectPath); const stats = await plugins.smartfs.file(destObjectPath).stat();
const md5 = await this.readMD5(destObjectPath, destMD5Path); const md5 = await this.readMD5(destObjectPath, destMD5Path);
return { size: stats.size, md5 }; return { size: stats.size, md5 };
@@ -432,25 +477,41 @@ export class FilesystemStore {
const hash = plugins.crypto.createHash('md5'); const hash = plugins.crypto.createHash('md5');
let totalSize = 0; let totalSize = 0;
return new Promise((resolve, reject) => { return new Promise(async (resolve, reject) => {
const output = plugins.fs.createWriteStream(destPath); // Get Web WritableStream from smartfs
const webWriteStream = await plugins.smartfs.file(destPath).writeStream();
const writer = webWriteStream.getWriter();
input.on('data', (chunk: Buffer) => { // Read from Node.js stream and write to Web stream
input.on('data', async (chunk: Buffer) => {
hash.update(chunk); hash.update(chunk);
totalSize += chunk.length; totalSize += chunk.length;
try {
await writer.write(new Uint8Array(chunk));
} catch (err) {
reject(err);
}
}); });
input.on('error', reject); input.on('error', (err) => {
output.on('error', reject); writer.abort(err);
reject(err);
});
input.pipe(output).on('finish', async () => { input.on('end', async () => {
const md5 = hash.digest('hex'); try {
await writer.close();
const md5 = hash.digest('hex');
// Save MD5 to separate file // Save MD5 to separate file
const md5Path = `${destPath}.md5`; const md5Path = `${destPath}.md5`;
await plugins.fs.promises.writeFile(md5Path, md5); await plugins.smartfs.file(md5Path).write(md5);
resolve({ size: totalSize, md5 }); resolve({ size: totalSize, md5 });
} catch (err) {
reject(err);
}
}); });
}); });
} }
@@ -461,22 +522,28 @@ export class FilesystemStore {
private async readMD5(objectPath: string, md5Path: string): Promise<string> { private async readMD5(objectPath: string, md5Path: string): Promise<string> {
try { try {
// Try to read cached MD5 // Try to read cached MD5
const md5 = await plugins.smartfile.fs.toStringSync(md5Path); const md5 = await plugins.smartfs.file(md5Path).encoding('utf8').read() as string;
return md5.trim(); return md5.trim();
} catch (err) { } catch (err) {
// Calculate MD5 if not cached // Calculate MD5 if not cached
return new Promise((resolve, reject) => { return new Promise(async (resolve, reject) => {
const hash = plugins.crypto.createHash('md5'); const hash = plugins.crypto.createHash('md5');
const stream = plugins.fs.createReadStream(objectPath);
stream.on('data', (chunk: Buffer) => hash.update(chunk)); try {
stream.on('end', async () => { const webStream = await plugins.smartfs.file(objectPath).readStream();
const md5 = hash.digest('hex'); const nodeStream = Readable.fromWeb(webStream as any);
// Cache it
await plugins.fs.promises.writeFile(md5Path, md5); nodeStream.on('data', (chunk: Buffer) => hash.update(chunk));
resolve(md5); nodeStream.on('end', async () => {
}); const md5 = hash.digest('hex');
stream.on('error', reject); // Cache it
await plugins.smartfs.file(md5Path).write(md5);
resolve(md5);
});
nodeStream.on('error', reject);
} catch (err) {
reject(err);
}
}); });
} }
} }
@@ -486,7 +553,7 @@ export class FilesystemStore {
*/ */
private async readMetadata(metadataPath: string): Promise<Record<string, string>> { private async readMetadata(metadataPath: string): Promise<Record<string, string>> {
try { try {
const content = await plugins.smartfile.fs.toStringSync(metadataPath); const content = await plugins.smartfs.file(metadataPath).encoding('utf8').read() as string;
return JSON.parse(content); return JSON.parse(content);
} catch (err) { } catch (err) {
return {}; return {};

130
ts/classes/logger.ts Normal file
View File

@@ -0,0 +1,130 @@
import type { ILoggingConfig } from '../index.js';
/**
* Log levels in order of severity
*/
const LOG_LEVELS = {
error: 0,
warn: 1,
info: 2,
debug: 3,
} as const;
type LogLevel = keyof typeof LOG_LEVELS;
/**
* Structured logger with configurable levels and formats
*/
export class Logger {
private config: Required<ILoggingConfig>;
private minLevel: number;
constructor(config: ILoggingConfig) {
// Apply defaults for any missing config
this.config = {
level: config.level ?? 'info',
format: config.format ?? 'text',
enabled: config.enabled ?? true,
};
this.minLevel = LOG_LEVELS[this.config.level];
}
/**
* Check if a log level should be output
*/
private shouldLog(level: LogLevel): boolean {
if (!this.config.enabled) {
return false;
}
return LOG_LEVELS[level] <= this.minLevel;
}
/**
* Format a log message
*/
private format(level: LogLevel, message: string, meta?: Record<string, any>): string {
const timestamp = new Date().toISOString();
if (this.config.format === 'json') {
return JSON.stringify({
timestamp,
level,
message,
...(meta || {}),
});
}
// Text format
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
}
/**
* Log at error level
*/
public error(message: string, meta?: Record<string, any>): void {
if (this.shouldLog('error')) {
console.error(this.format('error', message, meta));
}
}
/**
* Log at warn level
*/
public warn(message: string, meta?: Record<string, any>): void {
if (this.shouldLog('warn')) {
console.warn(this.format('warn', message, meta));
}
}
/**
* Log at info level
*/
public info(message: string, meta?: Record<string, any>): void {
if (this.shouldLog('info')) {
console.log(this.format('info', message, meta));
}
}
/**
* Log at debug level
*/
public debug(message: string, meta?: Record<string, any>): void {
if (this.shouldLog('debug')) {
console.log(this.format('debug', message, meta));
}
}
/**
* Log HTTP request
*/
public request(method: string, url: string, meta?: Record<string, any>): void {
this.info(`${method} ${url}`, meta);
}
/**
* Log HTTP response
*/
public response(method: string, url: string, statusCode: number, duration: number): void {
const level: LogLevel = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info';
if (this.shouldLog(level)) {
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
if (level === 'error') {
this.error(message, { statusCode, duration });
} else if (level === 'warn') {
this.warn(message, { statusCode, duration });
} else {
this.info(message, { statusCode, duration });
}
}
}
/**
* Log S3 error
*/
public s3Error(code: string, message: string, status: number): void {
this.error(`[S3Error] ${code}: ${message}`, { code, status });
}
}

View File

@@ -0,0 +1,238 @@
import * as plugins from '../plugins.js';
import { Readable } from 'stream';
/**
* Multipart upload metadata
*/
export interface IMultipartUpload {
uploadId: string;
bucket: string;
key: string;
initiated: Date;
parts: Map<number, IPartInfo>;
metadata: Record<string, string>;
}
/**
* Part information
*/
export interface IPartInfo {
partNumber: number;
etag: string;
size: number;
lastModified: Date;
}
/**
* Manages multipart upload state and storage
*/
export class MultipartUploadManager {
private uploads: Map<string, IMultipartUpload> = new Map();
private uploadDir: string;
constructor(private rootDir: string) {
this.uploadDir = plugins.path.join(rootDir, '.multipart');
}
/**
* Initialize multipart uploads directory
*/
public async initialize(): Promise<void> {
await plugins.smartfs.directory(this.uploadDir).recursive().create();
}
/**
* Generate a unique upload ID
*/
private generateUploadId(): string {
return plugins.crypto.randomBytes(16).toString('hex');
}
/**
* Initiate a new multipart upload
*/
public async initiateUpload(
bucket: string,
key: string,
metadata: Record<string, string>
): Promise<string> {
const uploadId = this.generateUploadId();
this.uploads.set(uploadId, {
uploadId,
bucket,
key,
initiated: new Date(),
parts: new Map(),
metadata,
});
// Create directory for this upload's parts
const uploadPath = plugins.path.join(this.uploadDir, uploadId);
await plugins.smartfs.directory(uploadPath).recursive().create();
return uploadId;
}
/**
* Upload a part
*/
public async uploadPart(
uploadId: string,
partNumber: number,
stream: Readable
): Promise<IPartInfo> {
const upload = this.uploads.get(uploadId);
if (!upload) {
throw new Error('No such upload');
}
const partPath = plugins.path.join(this.uploadDir, uploadId, `part-${partNumber}`);
// Write part to disk
const webWriteStream = await plugins.smartfs.file(partPath).writeStream();
const writer = webWriteStream.getWriter();
let size = 0;
const hash = plugins.crypto.createHash('md5');
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
await writer.write(new Uint8Array(buffer));
hash.update(buffer);
size += buffer.length;
}
await writer.close();
const etag = hash.digest('hex');
const partInfo: IPartInfo = {
partNumber,
etag,
size,
lastModified: new Date(),
};
upload.parts.set(partNumber, partInfo);
return partInfo;
}
/**
* Complete multipart upload - combine all parts
*/
public async completeUpload(
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<{ etag: string; size: number }> {
const upload = this.uploads.get(uploadId);
if (!upload) {
throw new Error('No such upload');
}
// Verify all parts are uploaded
for (const part of parts) {
const uploadedPart = upload.parts.get(part.PartNumber);
if (!uploadedPart) {
throw new Error(`Part ${part.PartNumber} not uploaded`);
}
// Normalize ETag format (remove quotes if present)
const normalizedETag = part.ETag.replace(/"/g, '');
if (uploadedPart.etag !== normalizedETag) {
throw new Error(`Part ${part.PartNumber} ETag mismatch`);
}
}
// Sort parts by part number
const sortedParts = parts.sort((a, b) => a.PartNumber - b.PartNumber);
// Combine parts into final object
const finalPath = plugins.path.join(this.uploadDir, uploadId, 'final');
const webWriteStream = await plugins.smartfs.file(finalPath).writeStream();
const writer = webWriteStream.getWriter();
const hash = plugins.crypto.createHash('md5');
let totalSize = 0;
for (const part of sortedParts) {
const partPath = plugins.path.join(this.uploadDir, uploadId, `part-${part.PartNumber}`);
// Read part and write to final file
const partContent = await plugins.smartfs.file(partPath).read();
const buffer = Buffer.isBuffer(partContent) ? partContent : Buffer.from(partContent as string);
await writer.write(new Uint8Array(buffer));
hash.update(buffer);
totalSize += buffer.length;
}
await writer.close();
const etag = hash.digest('hex');
return { etag, size: totalSize };
}
/**
* Get the final combined file path
*/
public getFinalPath(uploadId: string): string {
return plugins.path.join(this.uploadDir, uploadId, 'final');
}
/**
* Get upload metadata
*/
public getUpload(uploadId: string): IMultipartUpload | undefined {
return this.uploads.get(uploadId);
}
/**
* Abort multipart upload - clean up parts
*/
public async abortUpload(uploadId: string): Promise<void> {
const upload = this.uploads.get(uploadId);
if (!upload) {
throw new Error('No such upload');
}
// Delete upload directory
const uploadPath = plugins.path.join(this.uploadDir, uploadId);
await plugins.smartfs.directory(uploadPath).recursive().delete();
// Remove from memory
this.uploads.delete(uploadId);
}
/**
* Clean up upload after completion
*/
public async cleanupUpload(uploadId: string): Promise<void> {
const uploadPath = plugins.path.join(this.uploadDir, uploadId);
await plugins.smartfs.directory(uploadPath).recursive().delete();
this.uploads.delete(uploadId);
}
/**
* List all in-progress uploads for a bucket
*/
public listUploads(bucket?: string): IMultipartUpload[] {
const uploads = Array.from(this.uploads.values());
if (bucket) {
return uploads.filter((u) => u.bucket === bucket);
}
return uploads;
}
/**
* List parts for an upload
*/
public listParts(uploadId: string): IPartInfo[] {
const upload = this.uploads.get(uploadId);
if (!upload) {
throw new Error('No such upload');
}
return Array.from(upload.parts.values()).sort((a, b) => a.partNumber - b.partNumber);
}
}

View File

@@ -4,9 +4,11 @@ import { MiddlewareStack } from './middleware-stack.js';
import { S3Context } from './context.js'; import { S3Context } from './context.js';
import { FilesystemStore } from './filesystem-store.js'; import { FilesystemStore } from './filesystem-store.js';
import { S3Error } from './s3-error.js'; import { S3Error } from './s3-error.js';
import { Logger } from './logger.js';
import { ServiceController } from '../controllers/service.controller.js'; import { ServiceController } from '../controllers/service.controller.js';
import { BucketController } from '../controllers/bucket.controller.js'; import { BucketController } from '../controllers/bucket.controller.js';
import { ObjectController } from '../controllers/object.controller.js'; import { ObjectController } from '../controllers/object.controller.js';
import type { ISmarts3Config } from '../index.js';
export interface ISmarts3ServerOptions { export interface ISmarts3ServerOptions {
port?: number; port?: number;
@@ -14,6 +16,7 @@ export interface ISmarts3ServerOptions {
directory?: string; directory?: string;
cleanSlate?: boolean; cleanSlate?: boolean;
silent?: boolean; silent?: boolean;
config?: Required<ISmarts3Config>;
} }
/** /**
@@ -24,19 +27,58 @@ export class Smarts3Server {
private httpServer?: plugins.http.Server; private httpServer?: plugins.http.Server;
private router: S3Router; private router: S3Router;
private middlewares: MiddlewareStack; private middlewares: MiddlewareStack;
private store: FilesystemStore; public store: FilesystemStore; // Made public for direct access from Smarts3 class
private options: Required<ISmarts3ServerOptions>; private options: Required<Omit<ISmarts3ServerOptions, 'config'>>;
private config: Required<ISmarts3Config>;
private logger: Logger;
constructor(options: ISmarts3ServerOptions = {}) { constructor(options: ISmarts3ServerOptions = {}) {
this.options = { this.options = {
port: 3000, port: options.port ?? 3000,
address: '0.0.0.0', address: options.address ?? '0.0.0.0',
directory: plugins.path.join(process.cwd(), '.nogit/bucketsDir'), directory: options.directory ?? plugins.path.join(process.cwd(), '.nogit/bucketsDir'),
cleanSlate: false, cleanSlate: options.cleanSlate ?? false,
silent: false, silent: options.silent ?? false,
...options,
}; };
// Store config for middleware and feature configuration
// If no config provided, create minimal default (for backward compatibility)
this.config = options.config ?? {
server: {
port: this.options.port,
address: this.options.address,
silent: this.options.silent,
},
storage: {
directory: this.options.directory,
cleanSlate: this.options.cleanSlate,
},
auth: {
enabled: false,
credentials: [{ accessKeyId: 'S3RVER', secretAccessKey: 'S3RVER' }],
},
cors: {
enabled: false,
allowedOrigins: ['*'],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
allowedHeaders: ['*'],
exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
maxAge: 86400,
allowCredentials: false,
},
logging: {
level: 'info',
format: 'text',
enabled: true,
},
limits: {
maxObjectSize: 5 * 1024 * 1024 * 1024,
maxMetadataSize: 2048,
requestTimeout: 300000,
},
};
this.logger = new Logger(this.config.logging);
this.store = new FilesystemStore(this.options.directory); this.store = new FilesystemStore(this.options.directory);
this.router = new S3Router(); this.router = new S3Router();
this.middlewares = new MiddlewareStack(); this.middlewares = new MiddlewareStack();
@@ -49,20 +91,118 @@ export class Smarts3Server {
* Setup middleware stack * Setup middleware stack
*/ */
private setupMiddlewares(): void { private setupMiddlewares(): void {
// Logger middleware // CORS middleware (must be first to handle preflight requests)
if (!this.options.silent) { if (this.config.cors.enabled) {
this.middlewares.use(async (req, res, ctx, next) => { this.middlewares.use(async (req, res, ctx, next) => {
const start = Date.now(); const origin = req.headers.origin || req.headers.referer;
console.log(`${req.method} ${req.url}`);
console.log(` Headers:`, JSON.stringify(req.headers, null, 2).slice(0, 200)); // Check if origin is allowed
const allowedOrigins = this.config.cors.allowedOrigins || ['*'];
const isOriginAllowed =
allowedOrigins.includes('*') ||
(origin && allowedOrigins.includes(origin));
if (isOriginAllowed) {
// Set CORS headers
res.setHeader(
'Access-Control-Allow-Origin',
allowedOrigins.includes('*') ? '*' : origin || '*'
);
if (this.config.cors.allowCredentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// Handle preflight OPTIONS request
if (req.method === 'OPTIONS') {
res.setHeader(
'Access-Control-Allow-Methods',
(this.config.cors.allowedMethods || []).join(', ')
);
res.setHeader(
'Access-Control-Allow-Headers',
(this.config.cors.allowedHeaders || []).join(', ')
);
if (this.config.cors.maxAge) {
res.setHeader(
'Access-Control-Max-Age',
String(this.config.cors.maxAge)
);
}
res.writeHead(204);
res.end();
return; // Don't call next() for OPTIONS
}
// Set exposed headers for actual requests
if (this.config.cors.exposedHeaders && this.config.cors.exposedHeaders.length > 0) {
res.setHeader(
'Access-Control-Expose-Headers',
this.config.cors.exposedHeaders.join(', ')
);
}
}
await next(); await next();
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} - ${res.statusCode} (${duration}ms)`);
}); });
} }
// TODO: Add authentication middleware // Authentication middleware (simple static credentials)
// TODO: Add CORS middleware if (this.config.auth.enabled) {
this.middlewares.use(async (req, res, ctx, next) => {
const authHeader = req.headers.authorization;
// Extract access key from Authorization header
let accessKeyId: string | undefined;
if (authHeader) {
// Support multiple auth formats:
// 1. AWS accessKeyId:signature
// 2. AWS4-HMAC-SHA256 Credential=accessKeyId/date/region/service/aws4_request, ...
if (authHeader.startsWith('AWS ')) {
accessKeyId = authHeader.substring(4).split(':')[0];
} else if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
const credentialMatch = authHeader.match(/Credential=([^/]+)\//);
accessKeyId = credentialMatch ? credentialMatch[1] : undefined;
}
}
// Check if access key is valid
const isValid = this.config.auth.credentials.some(
(cred) => cred.accessKeyId === accessKeyId
);
if (!isValid) {
ctx.throw('AccessDenied', 'Access Denied');
return;
}
await next();
});
}
// Logger middleware
if (!this.options.silent && this.config.logging.enabled) {
this.middlewares.use(async (req, res, ctx, next) => {
const start = Date.now();
// Log request
this.logger.request(req.method || 'UNKNOWN', req.url || '/', {
headers: req.headers,
});
await next();
// Log response
const duration = Date.now() - start;
this.logger.response(
req.method || 'UNKNOWN',
req.url || '/',
res.statusCode || 500,
duration
);
});
}
} }
/** /**
@@ -122,11 +262,14 @@ export class Smarts3Server {
): Promise<void> { ): Promise<void> {
const s3Error = err instanceof S3Error ? err : S3Error.fromError(err); const s3Error = err instanceof S3Error ? err : S3Error.fromError(err);
if (!this.options.silent) { // Log the error
console.error(`[S3Error] ${s3Error.code}: ${s3Error.message}`); this.logger.s3Error(s3Error.code, s3Error.message, s3Error.status);
if (s3Error.status >= 500) {
console.error(err.stack || err); // Log stack trace for server errors
} if (s3Error.status >= 500) {
this.logger.debug('Error stack trace', {
stack: err.stack || err.toString(),
});
} }
// Send error response // Send error response
@@ -155,7 +298,10 @@ export class Smarts3Server {
// Create HTTP server // Create HTTP server
this.httpServer = plugins.http.createServer((req, res) => { this.httpServer = plugins.http.createServer((req, res) => {
this.handleRequest(req, res).catch((err) => { this.handleRequest(req, res).catch((err) => {
console.error('Fatal error in request handler:', err); this.logger.error('Fatal error in request handler', {
error: err.message,
stack: err.stack,
});
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' }); res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error'); res.end('Internal Server Error');
@@ -169,9 +315,7 @@ export class Smarts3Server {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
if (!this.options.silent) { this.logger.info(`S3 server listening on ${this.options.address}:${this.options.port}`);
console.log(`S3 server listening on ${this.options.address}:${this.options.port}`);
}
resolve(); resolve();
} }
}); });
@@ -191,9 +335,7 @@ export class Smarts3Server {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
if (!this.options.silent) { this.logger.info('S3 server stopped');
console.log('S3 server stopped');
}
resolve(); resolve();
} }
}); });

View File

@@ -2,101 +2,205 @@ import * as plugins from './plugins.js';
import * as paths from './paths.js'; import * as paths from './paths.js';
import { Smarts3Server } from './classes/smarts3-server.js'; import { Smarts3Server } from './classes/smarts3-server.js';
export interface ISmarts3ContructorOptions { /**
port?: number; * Authentication configuration
cleanSlate?: boolean; */
useCustomServer?: boolean; // Feature flag for custom server export interface IAuthConfig {
enabled: boolean;
credentials: Array<{
accessKeyId: string;
secretAccessKey: string;
}>;
} }
/**
* CORS configuration
*/
export interface ICorsConfig {
enabled: boolean;
allowedOrigins?: string[];
allowedMethods?: string[];
allowedHeaders?: string[];
exposedHeaders?: string[];
maxAge?: number;
allowCredentials?: boolean;
}
/**
* Logging configuration
*/
export interface ILoggingConfig {
level?: 'error' | 'warn' | 'info' | 'debug';
format?: 'text' | 'json';
enabled?: boolean;
}
/**
* Request limits configuration
*/
export interface ILimitsConfig {
maxObjectSize?: number;
maxMetadataSize?: number;
requestTimeout?: number;
}
/**
* Server configuration
*/
export interface IServerConfig {
port?: number;
address?: string;
silent?: boolean;
}
/**
* Storage configuration
*/
export interface IStorageConfig {
directory?: string;
cleanSlate?: boolean;
}
/**
* Complete smarts3 configuration
*/
export interface ISmarts3Config {
server?: IServerConfig;
storage?: IStorageConfig;
auth?: IAuthConfig;
cors?: ICorsConfig;
logging?: ILoggingConfig;
limits?: ILimitsConfig;
}
/**
* Default configuration values
*/
const DEFAULT_CONFIG: ISmarts3Config = {
server: {
port: 3000,
address: '0.0.0.0',
silent: false,
},
storage: {
directory: paths.bucketsDir,
cleanSlate: false,
},
auth: {
enabled: false,
credentials: [
{
accessKeyId: 'S3RVER',
secretAccessKey: 'S3RVER',
},
],
},
cors: {
enabled: false,
allowedOrigins: ['*'],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
allowedHeaders: ['*'],
exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
maxAge: 86400,
allowCredentials: false,
},
logging: {
level: 'info',
format: 'text',
enabled: true,
},
limits: {
maxObjectSize: 5 * 1024 * 1024 * 1024, // 5GB
maxMetadataSize: 2048,
requestTimeout: 300000, // 5 minutes
},
};
/**
* Merge user config with defaults (deep merge)
*/
function mergeConfig(userConfig: ISmarts3Config): Required<ISmarts3Config> {
return {
server: {
...DEFAULT_CONFIG.server!,
...(userConfig.server || {}),
},
storage: {
...DEFAULT_CONFIG.storage!,
...(userConfig.storage || {}),
},
auth: {
...DEFAULT_CONFIG.auth!,
...(userConfig.auth || {}),
},
cors: {
...DEFAULT_CONFIG.cors!,
...(userConfig.cors || {}),
},
logging: {
...DEFAULT_CONFIG.logging!,
...(userConfig.logging || {}),
},
limits: {
...DEFAULT_CONFIG.limits!,
...(userConfig.limits || {}),
},
};
}
/**
* Main Smarts3 class - production-ready S3-compatible server
*/
export class Smarts3 { export class Smarts3 {
// STATIC // STATIC
public static async createAndStart( public static async createAndStart(configArg: ISmarts3Config = {}) {
optionsArg: ConstructorParameters<typeof Smarts3>[0], const smartS3Instance = new Smarts3(configArg);
) {
const smartS3Instance = new Smarts3(optionsArg);
await smartS3Instance.start(); await smartS3Instance.start();
return smartS3Instance; return smartS3Instance;
} }
// INSTANCE // INSTANCE
public options: ISmarts3ContructorOptions; public config: Required<ISmarts3Config>;
public s3Instance: plugins.s3rver | Smarts3Server; public s3Instance: Smarts3Server;
constructor(optionsArg: ISmarts3ContructorOptions) { constructor(configArg: ISmarts3Config = {}) {
this.options = { this.config = mergeConfig(configArg);
useCustomServer: true, // Default to custom server
...optionsArg,
};
} }
public async start() { public async start() {
if (this.options.useCustomServer) { this.s3Instance = new Smarts3Server({
// Use new custom server port: this.config.server.port,
this.s3Instance = new Smarts3Server({ address: this.config.server.address,
port: this.options.port || 3000, directory: this.config.storage.directory,
address: '0.0.0.0', cleanSlate: this.config.storage.cleanSlate,
directory: paths.bucketsDir, silent: this.config.server.silent,
cleanSlate: this.options.cleanSlate || false, config: this.config, // Pass full config to server
silent: false, });
}); await this.s3Instance.start();
await this.s3Instance.start();
console.log('s3 server is running (custom implementation)'); if (!this.config.server.silent) {
} else { console.log('s3 server is running');
// Use legacy s3rver
if (this.options.cleanSlate) {
await plugins.smartfile.fs.ensureEmptyDir(paths.bucketsDir);
} else {
await plugins.smartfile.fs.ensureDir(paths.bucketsDir);
}
this.s3Instance = new plugins.s3rver({
port: this.options.port || 3000,
address: '0.0.0.0',
silent: false,
directory: paths.bucketsDir,
});
await (this.s3Instance as plugins.s3rver).run();
console.log('s3 server is running (legacy s3rver)');
} }
} }
public async getS3Descriptor( public async getS3Descriptor(
optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>, optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
): Promise<plugins.tsclass.storage.IS3Descriptor> { ): Promise<plugins.tsclass.storage.IS3Descriptor> {
if (this.options.useCustomServer && this.s3Instance instanceof Smarts3Server) { const descriptor = this.s3Instance.getS3Descriptor();
const descriptor = this.s3Instance.getS3Descriptor();
return {
...descriptor,
...(optionsArg ? optionsArg : {}),
};
}
// Legacy s3rver descriptor
return { return {
...{ ...descriptor,
accessKey: 'S3RVER',
accessSecret: 'S3RVER',
endpoint: '127.0.0.1',
port: this.options.port || 3000,
useSsl: false,
},
...(optionsArg ? optionsArg : {}), ...(optionsArg ? optionsArg : {}),
}; };
} }
public async createBucket(bucketNameArg: string) { public async createBucket(bucketNameArg: string) {
const smartbucketInstance = new plugins.smartbucket.SmartBucket( // Call the filesystem store directly instead of using the client library
await this.getS3Descriptor(), await this.s3Instance.store.createBucket(bucketNameArg);
); return { name: bucketNameArg };
const bucket = await smartbucketInstance.createBucket(bucketNameArg);
return bucket;
} }
public async stop() { public async stop() {
if (this.s3Instance instanceof Smarts3Server) { await this.s3Instance.stop();
await this.s3Instance.stop();
} else {
await (this.s3Instance as plugins.s3rver).close();
}
} }
} }

View File

@@ -3,24 +3,20 @@ import * as path from 'path';
import * as http from 'http'; import * as http from 'http';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as url from 'url'; import * as url from 'url';
import * as fs from 'fs';
export { path, http, crypto, url, fs }; export { path, http, crypto, url };
// @push.rocks scope // @push.rocks scope
import * as smartbucket from '@push.rocks/smartbucket'; import { SmartFs, SmartFsProviderNode } from '@push.rocks/smartfs';
import * as smartfile from '@push.rocks/smartfile';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import { SmartXml } from '@push.rocks/smartxml'; import { SmartXml } from '@push.rocks/smartxml';
export { smartbucket, smartfile, smartpath, SmartXml }; // Create SmartFs instance with Node.js provider
export const smartfs = new SmartFs(new SmartFsProviderNode());
export { smartpath, SmartXml };
// @tsclass scope // @tsclass scope
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
export { tsclass }; export { tsclass };
// thirdparty scope
import s3rver from 's3rver';
export { s3rver };