Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f178bde6 | |||
| ffaef5cb15 | |||
| d4cc1d43ea | |||
| 759becdd04 | |||
| 51e8836227 | |||
| 3c0a54e08b | |||
| c074a5d2ed | |||
| a9ba9de6be | |||
| 263e7a58b9 | |||
| 74b81d7ba8 | |||
| 0d4837184f | |||
| 7f3de92961 | |||
| a7bc902dd0 | |||
| 95d78d0d08 | |||
| b62cb0bc97 | |||
| 32346636e0 | |||
| 415ba3e76d | |||
| 6594f67d3e | |||
| 61974e0b54 | |||
| fc845956fa | |||
| eec1e09d2b | |||
| c3daf9d3f7 | |||
| 654f47b7fc | |||
| 18a2eb7e3f | |||
| 3ab667049a | |||
| 871f0e0b78 | |||
| f2bf3f1314 | |||
| a3b40923a5 | |||
| bdcfcee37a | |||
| d41878721c | |||
| 3f69d55f5f | |||
| ae015e433d |
@@ -6,7 +6,7 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,7 +3,6 @@
|
|||||||
# artifacts
|
# artifacts
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
pages/
|
|
||||||
|
|
||||||
# installs
|
# installs
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -17,4 +16,8 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
# custom
|
# AI
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
#------# custom
|
||||||
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||||
|
# * For C, use cpp
|
||||||
|
# * For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
language: typescript
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "smarts3"
|
||||||
148
changelog.md
148
changelog.md
@@ -1,29 +1,171 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-23 - 5.0.2 - fix(readme)
|
||||||
|
Clarify contribution agreement requirement in README
|
||||||
|
|
||||||
|
- Updated the Issue Reporting and Security section in readme.md to make it explicit that developers must sign and comply with the contribution agreement (and complete identification) before obtaining a code.foss.global account to submit pull requests.
|
||||||
|
|
||||||
|
## 2025-11-23 - 5.0.1 - fix(docs)
|
||||||
|
Clarify README wording about S3 compatibility and AWS SDK usage
|
||||||
|
|
||||||
|
- Update README wording to "Full S3 API compatibility" and clarify it works seamlessly with AWS SDK v3 and other S3 clients
|
||||||
|
|
||||||
|
## 2025-11-23 - 5.0.0 - BREAKING CHANGE(core)
|
||||||
|
Production-ready S3-compatible server: nested config, multipart uploads, CORS, structured logging, SmartFS migration and improved error handling
|
||||||
|
|
||||||
|
- Breaking change: configuration format migrated from flat to nested structure (server, storage, auth, cors, logging, limits). Update existing configs accordingly.
|
||||||
|
- Implemented full multipart upload support (initiate, upload part, complete, abort) with on-disk part management and final assembly.
|
||||||
|
- Added CORS middleware with configurable origins, methods, headers, exposed headers, maxAge and credentials support.
|
||||||
|
- Structured, configurable logging (levels: error|warn|info|debug; formats: text|json) and request/response logging middleware.
|
||||||
|
- Simple static credential authentication middleware (configurable list of credentials).
|
||||||
|
- Migrated filesystem operations to @push.rocks/smartfs (Web Streams interoperability) and removed smartbucket from production dependencies.
|
||||||
|
- Improved S3-compatible error handling and XML responses (S3Error class and XML utilities).
|
||||||
|
- Exposed Smarts3Server and made store/multipart managers accessible for tests and advanced usage; added helper methods like getS3Descriptor and createBucket.
|
||||||
|
|
||||||
|
## 2025-11-23 - 4.0.0 - BREAKING CHANGE(Smarts3)
|
||||||
|
Migrate Smarts3 configuration to nested server/storage objects and remove legacy flat config support
|
||||||
|
|
||||||
|
- Smarts3.createAndStart() and Smarts3 constructor now accept ISmarts3Config with nested `server` and `storage` objects.
|
||||||
|
- Removed support for the legacy flat config shape (top-level `port` and `cleanSlate`) / ILegacySmarts3Config.
|
||||||
|
- Updated tests to use new config shape (server:{ port, silent } and storage:{ cleanSlate }).
|
||||||
|
- mergeConfig and Smarts3Server now rely on the nested config shape; consumers must update their initialization code.
|
||||||
|
|
||||||
|
## 2025-11-23 - 3.2.0 - feat(multipart)
|
||||||
|
Add multipart upload support with MultipartUploadManager and controller integration
|
||||||
|
|
||||||
|
- Introduce MultipartUploadManager (ts/classes/multipart-manager.ts) to manage multipart upload lifecycle and store parts on disk
|
||||||
|
- Wire multipart manager into server and request context (S3Context, Smarts3Server) and initialize multipart storage on server start
|
||||||
|
- Add multipart-related routes and handlers in ObjectController: initiate (POST ?uploads), upload part (PUT ?partNumber&uploadId), complete (POST ?uploadId), and abort (DELETE ?uploadId)
|
||||||
|
- On complete, combine parts into final object and store via existing FilesystemStore workflow
|
||||||
|
- Expose multipart manager on Smarts3Server for controller access
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
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.
|
||||||
|
|
||||||
|
- Add Smarts3Server: native, Node.js http-based S3-compatible server (ts/classes/smarts3-server.ts)
|
||||||
|
- New routing and middleware system: S3Router and MiddlewareStack for pattern matching and middleware composition (ts/classes/router.ts, ts/classes/middleware-stack.ts)
|
||||||
|
- Introduce request context and helpers: S3Context for parsing requests, sending responses and XML (ts/classes/context.ts)
|
||||||
|
- Filesystem-backed storage: FilesystemStore with bucket/object operations, streaming uploads, MD5 handling and Windows-safe key encoding (ts/classes/filesystem-store.ts)
|
||||||
|
- S3 error handling: S3Error class that maps S3 error codes and produces XML error responses (ts/classes/s3-error.ts)
|
||||||
|
- Controllers for service, bucket and object operations with S3-compatible XML responses and copy/range support (ts/controllers/*.ts)
|
||||||
|
- XML utilities and SmartXml integration for consistent XML generation/parsing (ts/utils/xml.utils.ts, ts/plugins.ts)
|
||||||
|
- Expose native plugins (http, crypto, url, fs) and SmartXml via plugins.ts
|
||||||
|
- ts/index.ts: add useCustomServer option, default to custom server, export Smarts3Server and handle start/stop for both custom and legacy backends
|
||||||
|
- Add AWS SDK v3 integration test (test/test.aws-sdk.node.ts) to validate compatibility
|
||||||
|
- package.json: add @aws-sdk/client-s3 devDependency and @push.rocks/smartxml dependency
|
||||||
|
- Documentation: readme.md updated to describe native custom server and legacy s3rver compatibility
|
||||||
|
|
||||||
|
## 2025-11-20 - 2.2.7 - fix(core)
|
||||||
|
Update dependencies, code style and project config; add pnpm overrides and ignore AI folders
|
||||||
|
|
||||||
|
- Bump devDependencies and runtime dependencies (@git.zone/*, @push.rocks/*, @tsclass/tsclass, s3rver) to newer compatible versions
|
||||||
|
- Add pnpm.overrides entry to package.json and normalize repository URL format
|
||||||
|
- Code style and formatting fixes in TypeScript sources (ts/index.ts, ts/00_commitinfo_data.ts): whitespace, trailing commas, parameter formatting and minor API-return typing preserved
|
||||||
|
- tsconfig.json: simplify compiler options and compact exclude list
|
||||||
|
- Update .gitignore to add AI-related folders (.claude/, .serena/) to avoid accidental commits
|
||||||
|
- Documentation and changelog formatting tweaks (readme.md, changelog.md, npmextra.json) — whitespace/newline cleanups and expanded changelog entries
|
||||||
|
|
||||||
|
## 2025-08-16 - 2.2.6 - fix(Smarts3)
|
||||||
|
|
||||||
|
Allow overriding S3 descriptor; update dependencies, test config and documentation
|
||||||
|
|
||||||
|
- ts/index.ts: getS3Descriptor now accepts an optional Partial<IS3Descriptor> to override defaults (backwards compatible)
|
||||||
|
- package.json: updated devDependencies and runtime dependency versions (tstest, smartpath, tsclass, s3rver, etc.) and added packageManager field
|
||||||
|
- package.json: expanded test script to run tstest with --web --verbose --logfile --timeout 60
|
||||||
|
- test/test.ts: test instance port changed to 3333
|
||||||
|
- readme.md: major rewrite and expansion of usage examples, API reference and guides
|
||||||
|
- added project config files: .claude/settings.local.json and .serena/project.yml
|
||||||
|
|
||||||
|
## 2024-11-06 - 2.2.5 - fix(ci)
|
||||||
|
|
||||||
|
Corrected docker image URLs in Gitea workflows to match the correct domain format.
|
||||||
|
|
||||||
|
- Updated IMAGE environment variable in .gitea/workflows/default_nottags.yaml
|
||||||
|
- Updated IMAGE environment variable in .gitea/workflows/default_tags.yaml
|
||||||
|
|
||||||
|
## 2024-11-06 - 2.2.4 - fix(core)
|
||||||
|
|
||||||
|
Improve code style and update dependencies
|
||||||
|
|
||||||
|
- Updated @push.rocks/tapbundle to version ^5.4.3 in package.json.
|
||||||
|
- Fixed markdown formatting in readme.md.
|
||||||
|
- Improved code consistency in ts/00_commitinfo_data.ts, ts/plugins.ts, and test/test.ts.
|
||||||
|
|
||||||
## 2024-11-06 - 2.2.3 - fix(core)
|
## 2024-11-06 - 2.2.3 - fix(core)
|
||||||
|
|
||||||
Fix endpoint address from 'localhost' to '127.0.0.1' for better compatibility in Smarts3.getS3Descriptor
|
Fix endpoint address from 'localhost' to '127.0.0.1' for better compatibility in Smarts3.getS3Descriptor
|
||||||
|
|
||||||
- Corrected the endpoint address in Smarts3.getS3Descriptor to ensure proper functioning across different environments.
|
- Corrected the endpoint address in Smarts3.getS3Descriptor to ensure proper functioning across different environments.
|
||||||
|
|
||||||
## 2024-11-06 - 2.2.2 - fix(core)
|
## 2024-11-06 - 2.2.2 - fix(core)
|
||||||
|
|
||||||
Fixed function call for fastPut in the test suite to ensure proper file upload handling.
|
Fixed function call for fastPut in the test suite to ensure proper file upload handling.
|
||||||
|
|
||||||
- Updated dependencies in package.json to newer versions.
|
- Updated dependencies in package.json to newer versions.
|
||||||
- Corrected the function call in test suite for file upload.
|
- Corrected the function call in test suite for file upload.
|
||||||
|
|
||||||
## 2024-10-26 - 2.2.1 - fix(core)
|
## 2024-10-26 - 2.2.1 - fix(core)
|
||||||
|
|
||||||
Fix import and typings for improved compatibility
|
Fix import and typings for improved compatibility
|
||||||
|
|
||||||
- Corrected the type signature for `getS3Descriptor` to return `IS3Descriptor`.
|
- Corrected the type signature for `getS3Descriptor` to return `IS3Descriptor`.
|
||||||
- Fixed import structure and updated dependencies for consistent namespace usage across plugins.
|
- Fixed import structure and updated dependencies for consistent namespace usage across plugins.
|
||||||
|
|
||||||
## 2024-10-26 - 2.2.0 - feat(ci)
|
## 2024-10-26 - 2.2.0 - feat(ci)
|
||||||
|
|
||||||
Migrate CI/CD workflow from GitLab CI to Gitea CI
|
Migrate CI/CD workflow from GitLab CI to Gitea CI
|
||||||
|
|
||||||
- Added new Gitea CI workflows for both non-tag and tag-based pushes
|
- Added new Gitea CI workflows for both non-tag and tag-based pushes
|
||||||
- Removed existing GitLab CI configuration
|
- Removed existing GitLab CI configuration
|
||||||
|
|
||||||
## 2024-05-29 - 2.1.1 - Updates and minor changes
|
## 2024-05-29 - 2.1.1 - Updates and minor changes
|
||||||
|
|
||||||
Updates and changes based on minor configuration improvements and organizational shifts.
|
Updates and changes based on minor configuration improvements and organizational shifts.
|
||||||
|
|
||||||
- Updated description file.
|
- Updated description file.
|
||||||
@@ -32,22 +174,26 @@ Updates and changes based on minor configuration improvements and organizational
|
|||||||
- Shifted to new organizational scheme.
|
- Shifted to new organizational scheme.
|
||||||
|
|
||||||
## 2022-07-30 - 2.1.0 - Core improvements and fixes
|
## 2022-07-30 - 2.1.0 - Core improvements and fixes
|
||||||
|
|
||||||
Minor improvements and important core changes.
|
Minor improvements and important core changes.
|
||||||
|
|
||||||
- Removed tslint from the core setup.
|
- Removed tslint from the core setup.
|
||||||
|
|
||||||
## 2022-07-30 - 2.0.2 - Bucket creation improvement
|
## 2022-07-30 - 2.0.2 - Bucket creation improvement
|
||||||
|
|
||||||
Enhanced file structure management.
|
Enhanced file structure management.
|
||||||
|
|
||||||
- Improved bucket creation to store locally within the .nogit directory.
|
- Improved bucket creation to store locally within the .nogit directory.
|
||||||
|
|
||||||
## 2022-04-14 - 2.0.0 to 2.0.1 - Structural updates and fixes
|
## 2022-04-14 - 2.0.0 to 2.0.1 - Structural updates and fixes
|
||||||
|
|
||||||
This release focused on core updates and structural changes.
|
This release focused on core updates and structural changes.
|
||||||
|
|
||||||
- Reformatted the project structure.
|
- Reformatted the project structure.
|
||||||
- Core updates with minor fixes.
|
- Core updates with minor fixes.
|
||||||
|
|
||||||
## 2021-12-20 - 1.0.10 - ESM Transition
|
## 2021-12-20 - 1.0.10 - ESM Transition
|
||||||
|
|
||||||
Breaking changes and minor fixes, transitioning to ES Modules.
|
Breaking changes and minor fixes, transitioning to ES Modules.
|
||||||
|
|
||||||
- BREAKING CHANGE: Transitioned core setup to ESM.
|
- BREAKING CHANGE: Transitioned core setup to ESM.
|
||||||
|
|||||||
@@ -36,4 +36,4 @@
|
|||||||
"tsdoc": {
|
"tsdoc": {
|
||||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smarts3",
|
"name": "@push.rocks/smarts3",
|
||||||
"version": "2.2.3",
|
"version": "5.0.2",
|
||||||
"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",
|
||||||
@@ -9,16 +9,17 @@
|
|||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --web --verbose --logfile --timeout 60)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.2.0",
|
"@aws-sdk/client-s3": "^3.937.0",
|
||||||
"@git.zone/tsbundle": "^2.1.0",
|
"@git.zone/tsbuild": "^3.1.0",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsbundle": "^2.5.2",
|
||||||
"@git.zone/tstest": "^1.0.72",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@push.rocks/tapbundle": "^5.4.1",
|
"@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,12 +38,10 @@
|
|||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartbucket": "^3.0.23",
|
"@push.rocks/smartfs": "^1.1.0",
|
||||||
"@push.rocks/smartfile": "^11.0.21",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpath": "^5.0.5",
|
"@push.rocks/smartxml": "^2.0.0",
|
||||||
"@tsclass/tsclass": "^4.1.2",
|
"@tsclass/tsclass": "^9.3.0"
|
||||||
"@types/s3rver": "^3.7.0",
|
|
||||||
"s3rver": "^3.7.1"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"S3 Mock Server",
|
"S3 Mock Server",
|
||||||
@@ -65,9 +64,13 @@
|
|||||||
"homepage": "https://code.foss.global/push.rocks/smarts3#readme",
|
"homepage": "https://code.foss.global/push.rocks/smarts3#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://code.foss.global/push.rocks/smarts3.git"
|
"url": "https://code.foss.global/push.rocks/smarts3.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://code.foss.global/push.rocks/smarts3/issues"
|
"url": "https://code.foss.global/push.rocks/smarts3/issues"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13440
pnpm-lock.yaml
generated
13440
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
438
production-readiness.md
Normal file
438
production-readiness.md
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
670
readme.md
670
readme.md
@@ -1,199 +1,589 @@
|
|||||||
```markdown
|
# @push.rocks/smarts3 🚀
|
||||||
# @push.rocks/smarts3
|
|
||||||
|
|
||||||
A Node.js TypeScript package to create a local S3 endpoint for development and testing using mapped local directories, simulating AWS S3.
|
**Production-ready S3-compatible server** - A powerful, lightweight Node.js TypeScript package that brings full S3 API compatibility to your local filesystem. Perfect for development, testing, and scenarios where running MinIO is out of scope!
|
||||||
|
|
||||||
## Install
|
## 🌟 Features
|
||||||
|
|
||||||
To integrate `@push.rocks/smarts3` with your project, you need to install it via npm. Execute the following command within your project's root directory:
|
- 🏃 **Lightning-fast local S3 simulation** - No more waiting for cloud operations during development
|
||||||
|
- ⚡ **Production-ready architecture** - Built on Node.js http module with zero framework dependencies
|
||||||
|
- 🔄 **Full S3 API compatibility** - Works seamlessly with AWS SDK v3 and any other S3 client
|
||||||
|
- 📂 **Local directory mapping** - Your buckets live right on your filesystem
|
||||||
|
- 🔐 **Simple authentication** - Static credential-based auth for secure access
|
||||||
|
- 🌐 **CORS support** - Configurable cross-origin resource sharing
|
||||||
|
- 📊 **Structured logging** - Multiple levels (error/warn/info/debug) and formats (text/JSON)
|
||||||
|
- 📤 **Multipart uploads** - Full support for large file uploads (>5MB)
|
||||||
|
- 🧪 **Perfect for testing** - Reliable, repeatable tests without cloud dependencies
|
||||||
|
- 🎯 **TypeScript-first** - Built with TypeScript for excellent type safety and IDE support
|
||||||
|
- 🔧 **Flexible configuration** - Comprehensive config system with sensible defaults
|
||||||
|
- 🧹 **Clean slate mode** - Start fresh on every test run
|
||||||
|
|
||||||
```sh
|
## Issue Reporting and Security
|
||||||
npm install @push.rocks/smarts3 --save
|
|
||||||
|
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 sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
Install using your favorite package manager:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using npm
|
||||||
|
npm install @push.rocks/smarts3 --save-dev
|
||||||
|
|
||||||
|
# Using pnpm (recommended)
|
||||||
|
pnpm add @push.rocks/smarts3 -D
|
||||||
|
|
||||||
|
# Using yarn
|
||||||
|
yarn add @push.rocks/smarts3 --dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will add `@push.rocks/smarts3` as a dependency in your project's `package.json` file and download the package into the `node_modules` directory.
|
## 🚀 Quick Start
|
||||||
|
|
||||||
## Usage
|
Get up and running in seconds:
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
The `@push.rocks/smarts3` module allows users to create a mock S3 endpoint that maps to a local directory using `s3rver`. This simulation of AWS S3 operations facilitates development and testing by enabling file uploads, bucket creation, and other interactions locally. This local setup is ideal for developers looking to test cloud file storage operations without requiring access to a real AWS S3 instance.
|
|
||||||
|
|
||||||
In this comprehensive guide, we will explore setting up a local S3 server, performing operations like creating buckets and uploading files, and how to effectively integrate this into your development workflow.
|
|
||||||
|
|
||||||
### Setting Up the Environment
|
|
||||||
|
|
||||||
To begin any operations, your environment must be configured correctly. Here’s a simple setup procedure:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import * as path from 'path';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
|
|
||||||
async function setupEnvironment() {
|
|
||||||
const packageDir = path.resolve();
|
|
||||||
const nogitDir = path.join(packageDir, './.nogit');
|
|
||||||
const bucketsDir = path.join(nogitDir, 'bucketsDir');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(bucketsDir, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create buckets directory!', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Environment setup complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEnvironment().catch(console.error);
|
|
||||||
```
|
|
||||||
|
|
||||||
This script sets up a directory structure required for the `smarts3` server, ensuring that the directories needed for bucket storage exist before starting the server.
|
|
||||||
|
|
||||||
### Starting the S3 Server
|
|
||||||
|
|
||||||
Once your environment is set up, start an instance of the `smarts3` server. This acts as your local mock S3 endpoint:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Smarts3 } from '@push.rocks/smarts3';
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
|
|
||||||
async function startServer() {
|
// Start your local S3 server with minimal config
|
||||||
const smarts3Instance = await Smarts3.createAndStart({
|
const s3Server = await Smarts3.createAndStart({
|
||||||
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
cleanSlate: true,
|
silent: false,
|
||||||
});
|
},
|
||||||
|
storage: {
|
||||||
|
cleanSlate: true, // Start with empty buckets
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
console.log('S3 server is up and running at http://localhost:3000');
|
// Create a bucket
|
||||||
return smarts3Instance;
|
const bucket = await s3Server.createBucket('my-awesome-bucket');
|
||||||
}
|
|
||||||
|
|
||||||
startServer().catch(console.error);
|
// Get S3 connection details for use with AWS SDK or other S3 clients
|
||||||
|
const s3Config = await s3Server.getS3Descriptor();
|
||||||
|
|
||||||
|
// When you're done
|
||||||
|
await s3Server.stop();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
## 📖 Configuration Guide
|
||||||
- **Port**: Specify the port for the local S3 server. Defaults to `3000`.
|
|
||||||
- **CleanSlate**: If `true`, clears the storage directory each time the server starts, providing a fresh test state.
|
|
||||||
|
|
||||||
### Creating and Managing Buckets
|
### Complete Configuration Options
|
||||||
|
|
||||||
With your server running, create buckets for storing files. A bucket in S3 acts similarly to a root directory.
|
Smarts3 uses a comprehensive nested configuration structure:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function createBucket(smarts3Instance: Smarts3, bucketName: string) {
|
import { Smarts3, ISmarts3Config } from '@push.rocks/smarts3';
|
||||||
const bucket = await smarts3Instance.createBucket(bucketName);
|
|
||||||
console.log(`Bucket created: ${bucket.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
startServer()
|
const config: ISmarts3Config = {
|
||||||
.then((smarts3Instance) => createBucket(smarts3Instance, 'my-awesome-bucket'))
|
// Server configuration
|
||||||
.catch(console.error);
|
server: {
|
||||||
|
port: 3000, // Port to listen on (default: 3000)
|
||||||
|
address: '0.0.0.0', // Bind address (default: '0.0.0.0')
|
||||||
|
silent: false, // Disable all console output (default: false)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Storage configuration
|
||||||
|
storage: {
|
||||||
|
directory: './buckets', // Directory to store buckets (default: .nogit/bucketsDir)
|
||||||
|
cleanSlate: false, // Clear all data on start (default: false)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authentication configuration
|
||||||
|
auth: {
|
||||||
|
enabled: false, // Enable authentication (default: false)
|
||||||
|
credentials: [ // List of valid credentials
|
||||||
|
{
|
||||||
|
accessKeyId: 'YOUR_ACCESS_KEY',
|
||||||
|
secretAccessKey: 'YOUR_SECRET_KEY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
cors: {
|
||||||
|
enabled: false, // Enable CORS (default: false)
|
||||||
|
allowedOrigins: ['*'], // Allowed origins (default: ['*'])
|
||||||
|
allowedMethods: [ // Allowed HTTP methods
|
||||||
|
'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'
|
||||||
|
],
|
||||||
|
allowedHeaders: ['*'], // Allowed headers (default: ['*'])
|
||||||
|
exposedHeaders: [ // Headers exposed to client
|
||||||
|
'ETag', 'x-amz-request-id', 'x-amz-version-id'
|
||||||
|
],
|
||||||
|
maxAge: 86400, // Preflight cache duration in seconds
|
||||||
|
allowCredentials: false, // Allow credentials (default: false)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logging configuration
|
||||||
|
logging: {
|
||||||
|
level: 'info', // Log level: 'error' | 'warn' | 'info' | 'debug'
|
||||||
|
format: 'text', // Log format: 'text' | 'json'
|
||||||
|
enabled: true, // Enable logging (default: true)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request limits
|
||||||
|
limits: {
|
||||||
|
maxObjectSize: 5 * 1024 * 1024 * 1024, // 5GB max object size
|
||||||
|
maxMetadataSize: 2048, // 2KB max metadata size
|
||||||
|
requestTimeout: 300000, // 5 minutes request timeout
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const s3Server = await Smarts3.createAndStart(config);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Uploading and Managing Files
|
### Simple Configuration Examples
|
||||||
|
|
||||||
Uploading files to a bucket uses the `SmartBucket` module, part of the `@push.rocks/smartbucket` ecosystem:
|
**Development Mode (Default)**
|
||||||
|
```typescript
|
||||||
|
const s3Server = await Smarts3.createAndStart({
|
||||||
|
server: { port: 3000 },
|
||||||
|
storage: { cleanSlate: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production Mode with Auth**
|
||||||
|
```typescript
|
||||||
|
const s3Server = await Smarts3.createAndStart({
|
||||||
|
server: { port: 3000 },
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
level: 'warn',
|
||||||
|
format: 'json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**CORS-Enabled for Web Apps**
|
||||||
|
```typescript
|
||||||
|
const s3Server = await Smarts3.createAndStart({
|
||||||
|
server: { port: 3000 },
|
||||||
|
cors: {
|
||||||
|
enabled: true,
|
||||||
|
allowedOrigins: ['http://localhost:8080', 'https://app.example.com'],
|
||||||
|
allowCredentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🪣 Working with Buckets
|
||||||
|
|
||||||
|
### Creating Buckets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a new bucket
|
||||||
|
const bucket = await s3Server.createBucket('my-bucket');
|
||||||
|
console.log(`Created bucket: ${bucket.name}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📤 File Operations
|
||||||
|
|
||||||
|
### Using AWS SDK v3
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
// Get connection config
|
||||||
|
const config = await s3Server.getS3Descriptor();
|
||||||
|
|
||||||
|
// Configure AWS SDK client
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
endpoint: `http://${config.endpoint}:${config.port}`,
|
||||||
|
region: 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.accessSecret,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
await s3Client.send(new PutObjectCommand({
|
||||||
|
Bucket: 'my-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
Body: 'Hello from AWS SDK!',
|
||||||
|
ContentType: 'text/plain',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Download a file
|
||||||
|
const response = await s3Client.send(new GetObjectCommand({
|
||||||
|
Bucket: 'my-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const content = await response.Body.transformToString();
|
||||||
|
console.log(content); // "Hello from AWS SDK!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using SmartBucket
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartBucket } from '@push.rocks/smartbucket';
|
import { SmartBucket } from '@push.rocks/smartbucket';
|
||||||
|
|
||||||
async function uploadFile(smarts3Instance: Smarts3, bucketName: string, filePath: string, fileContent: string) {
|
// Get connection configuration
|
||||||
const s3Descriptor = await smarts3Instance.getS3Descriptor();
|
const s3Config = await s3Server.getS3Descriptor();
|
||||||
const smartbucketInstance = new SmartBucket(s3Descriptor);
|
|
||||||
const bucket = await smartbucketInstance.getBucket(bucketName);
|
|
||||||
|
|
||||||
await bucket.getBaseDirectory().fastStore(filePath, fileContent);
|
// Create a SmartBucket instance
|
||||||
console.log(`File "${filePath}" uploaded successfully to bucket "${bucketName}".`);
|
const smartbucket = new SmartBucket(s3Config);
|
||||||
}
|
const bucket = await smartbucket.getBucket('my-bucket');
|
||||||
|
const baseDir = await bucket.getBaseDirectory();
|
||||||
|
|
||||||
startServer()
|
// Upload files
|
||||||
.then(async (smarts3Instance) => {
|
await baseDir.fastStore('path/to/file.txt', 'Hello, S3! 🎉');
|
||||||
await createBucket(smarts3Instance, 'my-awesome-bucket');
|
await baseDir.fastPut({
|
||||||
await uploadFile(smarts3Instance, 'my-awesome-bucket', 'hello.txt', 'Hello, world!');
|
path: 'documents/important.pdf',
|
||||||
})
|
contents: Buffer.from(yourPdfData),
|
||||||
.catch(console.error);
|
});
|
||||||
|
|
||||||
|
// Download files
|
||||||
|
const content = await baseDir.fastGet('path/to/file.txt');
|
||||||
|
const buffer = await baseDir.fastGetBuffer('documents/important.pdf');
|
||||||
|
|
||||||
|
// List files
|
||||||
|
const files = await baseDir.listFiles();
|
||||||
|
files.forEach((file) => {
|
||||||
|
console.log(`📄 ${file.name} (${file.size} bytes)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete files
|
||||||
|
await baseDir.fastDelete('old-file.txt');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Listing Files in a Bucket
|
## 📤 Multipart Uploads
|
||||||
|
|
||||||
Listing files within a bucket allows you to manage its contents conveniently:
|
Smarts3 supports multipart uploads for large files (>5MB):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function listFiles(smarts3Instance: Smarts3, bucketName: string) {
|
import {
|
||||||
const s3Descriptor = await smarts3Instance.getS3Descriptor();
|
S3Client,
|
||||||
const smartbucketInstance = new SmartBucket(s3Descriptor);
|
CreateMultipartUploadCommand,
|
||||||
const bucket = await smartbucketInstance.getBucket(bucketName);
|
UploadPartCommand,
|
||||||
|
CompleteMultipartUploadCommand
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
const baseDirectory = await bucket.getBaseDirectory();
|
const s3Client = new S3Client(/* ... */);
|
||||||
const files = await baseDirectory.listFiles();
|
|
||||||
|
|
||||||
console.log(`Files in bucket "${bucketName}":`, files);
|
// 1. Initiate multipart upload
|
||||||
|
const { UploadId } = await s3Client.send(new CreateMultipartUploadCommand({
|
||||||
|
Bucket: 'my-bucket',
|
||||||
|
Key: 'large-file.bin',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Upload parts (in parallel if desired)
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < numParts; i++) {
|
||||||
|
const part = await s3Client.send(new UploadPartCommand({
|
||||||
|
Bucket: 'my-bucket',
|
||||||
|
Key: 'large-file.bin',
|
||||||
|
UploadId,
|
||||||
|
PartNumber: i + 1,
|
||||||
|
Body: partData[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
PartNumber: i + 1,
|
||||||
|
ETag: part.ETag,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer()
|
// 3. Complete the upload
|
||||||
.then(async (smarts3Instance) => {
|
await s3Client.send(new CompleteMultipartUploadCommand({
|
||||||
await createBucket(smarts3Instance, 'my-awesome-bucket');
|
Bucket: 'my-bucket',
|
||||||
await listFiles(smarts3Instance, 'my-awesome-bucket');
|
Key: 'large-file.bin',
|
||||||
})
|
UploadId,
|
||||||
.catch(console.error);
|
MultipartUpload: { Parts: parts },
|
||||||
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deleting a File
|
## 🧪 Testing Integration
|
||||||
|
|
||||||
Managing storage efficiently involves deleting files when necessary:
|
### Using with Jest
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function deleteFile(smarts3Instance: Smarts3, bucketName: string, filePath: string) {
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
const s3Descriptor = await smarts3Instance.getS3Descriptor();
|
|
||||||
const smartbucketInstance = new SmartBucket(s3Descriptor);
|
|
||||||
const bucket = await smartbucketInstance.getBucket(bucketName);
|
|
||||||
|
|
||||||
await bucket.getBaseDirectory().fastDelete(filePath);
|
describe('S3 Operations', () => {
|
||||||
console.log(`File "${filePath}" deleted from bucket "${bucketName}".`);
|
let s3Server: Smarts3;
|
||||||
}
|
|
||||||
|
|
||||||
startServer()
|
beforeAll(async () => {
|
||||||
.then(async (smarts3Instance) => {
|
s3Server = await Smarts3.createAndStart({
|
||||||
await createBucket(smarts3Instance, 'my-awesome-bucket');
|
server: { port: 9999, silent: true },
|
||||||
await deleteFile(smarts3Instance, 'my-awesome-bucket', 'hello.txt');
|
storage: { cleanSlate: true },
|
||||||
})
|
});
|
||||||
.catch(console.error);
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await s3Server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should upload and retrieve a file', async () => {
|
||||||
|
const bucket = await s3Server.createBucket('test-bucket');
|
||||||
|
// Your test logic here
|
||||||
|
});
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scenario Integrations
|
### Using with Mocha
|
||||||
|
|
||||||
#### Development and Testing
|
|
||||||
|
|
||||||
1. **Feature Development:** Use `@push.rocks/smarts3` to simulate file upload endpoints, ensuring your application handles file operations correctly before going live.
|
|
||||||
|
|
||||||
2. **Continuous Integration/Continuous Deployment (CI/CD):** Integrate with CI/CD pipelines to automatically test file interactions.
|
|
||||||
|
|
||||||
3. **Data Migration Testing:** Simulate data migrations between buckets to perfect processes before implementation on actual S3.
|
|
||||||
|
|
||||||
4. **Onboarding New Developers:** Offer new team members hands-on practice with mock setups to improve their understanding without real-world consequences.
|
|
||||||
|
|
||||||
### Stopping the Server
|
|
||||||
|
|
||||||
Safely shutting down the server when tasks are complete ensures system resources are managed well:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function stopServer(smarts3Instance: Smarts3) {
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
await smarts3Instance.stop();
|
import { expect } from 'chai';
|
||||||
console.log('S3 server has been stopped.');
|
|
||||||
|
describe('S3 Operations', () => {
|
||||||
|
let s3Server: Smarts3;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
s3Server = await Smarts3.createAndStart({
|
||||||
|
server: { port: 9999, silent: true },
|
||||||
|
storage: { cleanSlate: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await s3Server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upload and retrieve a file', async () => {
|
||||||
|
const bucket = await s3Server.createBucket('test-bucket');
|
||||||
|
// Your test logic here
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Real-World Use Cases
|
||||||
|
|
||||||
|
### CI/CD Pipeline Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ci-test.ts
|
||||||
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
|
|
||||||
|
export async function setupTestEnvironment() {
|
||||||
|
const s3 = await Smarts3.createAndStart({
|
||||||
|
server: {
|
||||||
|
port: process.env.S3_PORT || 3000,
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
storage: { cleanSlate: true },
|
||||||
|
logging: { level: 'error' }, // Only log errors in CI
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test buckets
|
||||||
|
await s3.createBucket('uploads');
|
||||||
|
await s3.createBucket('processed');
|
||||||
|
await s3.createBucket('archive');
|
||||||
|
|
||||||
|
return s3;
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer()
|
|
||||||
.then(async (smarts3Instance) => {
|
|
||||||
await createBucket(smarts3Instance, 'my-awesome-bucket');
|
|
||||||
await stopServer(smarts3Instance);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In this guide, we walked through setting up and fully utilizing the `@push.rocks/smarts3` package for local development and testing. The package simulates AWS S3 operations, reducing dependency on remote services and allowing efficient development iteration cycles. By implementing the practices and scripts shared here, you can ensure a seamless and productive development experience using the local S3 simulation capabilities of `@push.rocks/smarts3`.
|
### Microservice Development
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// dev-server.ts
|
||||||
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
async function startDevelopmentServer() {
|
||||||
|
// Start local S3 with CORS for local development
|
||||||
|
const s3 = await Smarts3.createAndStart({
|
||||||
|
server: { port: 3000 },
|
||||||
|
cors: {
|
||||||
|
enabled: true,
|
||||||
|
allowedOrigins: ['http://localhost:8080'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3.createBucket('user-uploads');
|
||||||
|
|
||||||
|
// Start your API server
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.post('/upload', async (req, res) => {
|
||||||
|
// Your upload logic using local S3
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(8080, () => {
|
||||||
|
console.log('🚀 Dev server running with local S3!');
|
||||||
|
});
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Data Migration Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Smarts3 } from '@push.rocks/smarts3';
|
||||||
|
import { SmartBucket } from '@push.rocks/smartbucket';
|
||||||
|
|
||||||
|
async function testDataMigration() {
|
||||||
|
const s3 = await Smarts3.createAndStart({
|
||||||
|
storage: { cleanSlate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create source and destination buckets
|
||||||
|
await s3.createBucket('legacy-data');
|
||||||
|
await s3.createBucket('new-data');
|
||||||
|
|
||||||
|
// Populate source with test data
|
||||||
|
const config = await s3.getS3Descriptor();
|
||||||
|
const smartbucket = new SmartBucket(config);
|
||||||
|
const source = await smartbucket.getBucket('legacy-data');
|
||||||
|
const sourceDir = await source.getBaseDirectory();
|
||||||
|
|
||||||
|
await sourceDir.fastStore('user-1.json', JSON.stringify({ id: 1, name: 'Alice' }));
|
||||||
|
await sourceDir.fastStore('user-2.json', JSON.stringify({ id: 2, name: 'Bob' }));
|
||||||
|
|
||||||
|
// Run your migration logic
|
||||||
|
await runMigration(config);
|
||||||
|
|
||||||
|
// Verify migration results
|
||||||
|
const dest = await smartbucket.getBucket('new-data');
|
||||||
|
const destDir = await dest.getBaseDirectory();
|
||||||
|
const migratedFiles = await destDir.listFiles();
|
||||||
|
|
||||||
|
console.log(`✅ Migrated ${migratedFiles.length} files successfully!`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 API Reference
|
||||||
|
|
||||||
|
### Smarts3 Class
|
||||||
|
|
||||||
|
#### Static Methods
|
||||||
|
|
||||||
|
##### `createAndStart(config?: ISmarts3Config): Promise<Smarts3>`
|
||||||
|
|
||||||
|
Create and start a Smarts3 instance in one call.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `config` - Optional configuration object (see Configuration Guide above)
|
||||||
|
|
||||||
|
**Returns:** Promise that resolves to a running Smarts3 instance
|
||||||
|
|
||||||
|
#### Instance Methods
|
||||||
|
|
||||||
|
##### `start(): Promise<void>`
|
||||||
|
|
||||||
|
Start the S3 server.
|
||||||
|
|
||||||
|
##### `stop(): Promise<void>`
|
||||||
|
|
||||||
|
Stop the S3 server and release resources.
|
||||||
|
|
||||||
|
##### `createBucket(name: string): Promise<{ name: string }>`
|
||||||
|
|
||||||
|
Create a new S3 bucket.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `name` - Bucket name
|
||||||
|
|
||||||
|
**Returns:** Promise that resolves to bucket information
|
||||||
|
|
||||||
|
##### `getS3Descriptor(options?): Promise<IS3Descriptor>`
|
||||||
|
|
||||||
|
Get S3 connection configuration for use with S3 clients.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `options` - Optional partial descriptor to merge with defaults
|
||||||
|
|
||||||
|
**Returns:** Promise that resolves to S3 descriptor with:
|
||||||
|
- `accessKey` - Access key for authentication
|
||||||
|
- `accessSecret` - Secret key for authentication
|
||||||
|
- `endpoint` - Server endpoint (hostname/IP)
|
||||||
|
- `port` - Server port
|
||||||
|
- `useSsl` - Whether to use SSL (always false for local server)
|
||||||
|
|
||||||
|
## 💡 Production Considerations
|
||||||
|
|
||||||
|
### When to Use Smarts3 vs MinIO
|
||||||
|
|
||||||
|
**Use Smarts3 when:**
|
||||||
|
- 🎯 You need a lightweight, zero-dependency S3 server
|
||||||
|
- 🧪 Running in CI/CD pipelines or containerized test environments
|
||||||
|
- 🏗️ Local development where MinIO setup is overkill
|
||||||
|
- 📦 Your application needs to bundle an S3-compatible server
|
||||||
|
- 🚀 Quick prototyping without infrastructure setup
|
||||||
|
|
||||||
|
**Use MinIO when:**
|
||||||
|
- 🏢 Production workloads requiring high availability
|
||||||
|
- 📊 Advanced features like versioning, replication, encryption at rest
|
||||||
|
- 🔐 Complex IAM policies and bucket policies
|
||||||
|
- 📈 High-performance requirements with multiple nodes
|
||||||
|
- 🌐 Multi-tenant environments
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Smarts3's authentication is intentionally simple (static credentials)
|
||||||
|
- It does **not** implement AWS Signature V4 verification
|
||||||
|
- Perfect for development/testing, but not for production internet-facing deployments
|
||||||
|
- For production use, place behind a reverse proxy with proper authentication
|
||||||
|
|
||||||
|
## 🐛 Debugging Tips
|
||||||
|
|
||||||
|
1. **Enable debug logging**
|
||||||
|
```typescript
|
||||||
|
const s3 = await Smarts3.createAndStart({
|
||||||
|
logging: { level: 'debug', format: 'json' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check the buckets directory** - Find your data in `.nogit/bucketsDir/` by default
|
||||||
|
|
||||||
|
3. **Use the correct endpoint** - Remember to use `127.0.0.1` or `localhost`
|
||||||
|
|
||||||
|
4. **Force path style** - Always use `forcePathStyle: true` with local S3
|
||||||
|
|
||||||
|
5. **Inspect requests** - All requests are logged when `silent: false`
|
||||||
|
|
||||||
|
## 📈 Performance
|
||||||
|
|
||||||
|
Smarts3 is optimized for development and testing scenarios:
|
||||||
|
|
||||||
|
- ⚡ **Instant operations** - No network latency
|
||||||
|
- 💾 **Low memory footprint** - Efficient filesystem operations with streams
|
||||||
|
- 🔄 **Fast cleanup** - Clean slate mode for quick test resets
|
||||||
|
- 🚀 **Parallel operations** - Handle multiple concurrent requests
|
||||||
|
- 📤 **Streaming uploads/downloads** - Low memory usage for large files
|
||||||
|
|
||||||
|
## 🔗 Related Packages
|
||||||
|
|
||||||
|
- [`@push.rocks/smartbucket`](https://www.npmjs.com/package/@push.rocks/smartbucket) - Powerful S3 abstraction layer
|
||||||
|
- [`@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
|
||||||
|
|
||||||
|
## 📝 Changelog
|
||||||
|
|
||||||
|
### v4.0.0 - Production Ready 🚀
|
||||||
|
|
||||||
|
**Breaking Changes:**
|
||||||
|
- Configuration format changed from flat to nested structure
|
||||||
|
- Old format: `{ port: 3000, cleanSlate: true }`
|
||||||
|
- New format: `{ server: { port: 3000 }, storage: { cleanSlate: true } }`
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- ✨ Production configuration system with comprehensive options
|
||||||
|
- 📊 Structured logging with multiple levels and formats
|
||||||
|
- 🌐 Full CORS middleware support
|
||||||
|
- 🔐 Simple static credentials authentication
|
||||||
|
- 📤 Complete multipart upload support for large files
|
||||||
|
- 🔧 Flexible configuration with sensible defaults
|
||||||
|
|
||||||
|
**Improvements:**
|
||||||
|
- Removed smartbucket from production dependencies (dev-only)
|
||||||
|
- Migrated to @push.rocks/smartfs for modern filesystem operations
|
||||||
|
- Enhanced error handling and logging throughout
|
||||||
|
- Better TypeScript types and documentation
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
@@ -203,7 +593,7 @@ This project is owned and maintained by Task Venture Capital GmbH. The names and
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
Registered at District court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||||
|
|||||||
108
test/test.aws-sdk.node.ts
Normal file
108
test/test.aws-sdk.node.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { S3Client, CreateBucketCommand, ListBucketsCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteBucketCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import * as smarts3 from '../ts/index.js';
|
||||||
|
|
||||||
|
let testSmarts3Instance: smarts3.Smarts3;
|
||||||
|
let s3Client: S3Client;
|
||||||
|
|
||||||
|
// Helper to convert stream to string
|
||||||
|
async function streamToString(stream: Readable): Promise<string> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('should start the S3 server and configure client', async () => {
|
||||||
|
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||||
|
server: {
|
||||||
|
port: 3337,
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
cleanSlate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const descriptor = await testSmarts3Instance.getS3Descriptor();
|
||||||
|
|
||||||
|
s3Client = new S3Client({
|
||||||
|
endpoint: `http://${descriptor.endpoint}:${descriptor.port}`,
|
||||||
|
region: 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: descriptor.accessKey,
|
||||||
|
secretAccessKey: descriptor.accessSecret,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list buckets (empty)', async () => {
|
||||||
|
const response = await s3Client.send(new ListBucketsCommand({}));
|
||||||
|
expect(Array.isArray(response.Buckets)).toEqual(true);
|
||||||
|
expect(response.Buckets!.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create a bucket', async () => {
|
||||||
|
const response = await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list buckets (showing created bucket)', async () => {
|
||||||
|
const response = await s3Client.send(new ListBucketsCommand({}));
|
||||||
|
expect(response.Buckets!.length).toEqual(1);
|
||||||
|
expect(response.Buckets![0].Name).toEqual('test-bucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should upload an object', async () => {
|
||||||
|
const response = await s3Client.send(new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
Body: 'Hello from AWS SDK!',
|
||||||
|
ContentType: 'text/plain',
|
||||||
|
}));
|
||||||
|
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||||
|
expect(response.ETag).toBeTypeofString();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should download the object', async () => {
|
||||||
|
const response = await s3Client.send(new GetObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||||
|
const content = await streamToString(response.Body as Readable);
|
||||||
|
expect(content).toEqual('Hello from AWS SDK!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should delete the object', async () => {
|
||||||
|
const response = await s3Client.send(new DeleteObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
}));
|
||||||
|
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fail to get deleted object', async () => {
|
||||||
|
await expect(
|
||||||
|
s3Client.send(new GetObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test-file.txt',
|
||||||
|
}))
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should delete the bucket', async () => {
|
||||||
|
const response = await s3Client.send(new DeleteBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop the S3 server', async () => {
|
||||||
|
await testSmarts3Instance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
12
test/test.ts
12
test/test.ts
@@ -1,4 +1,4 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
import * as smarts3 from '../ts/index.js';
|
import * as smarts3 from '../ts/index.js';
|
||||||
@@ -7,8 +7,12 @@ let testSmarts3Instance: smarts3.Smarts3;
|
|||||||
|
|
||||||
tap.test('should create a smarts3 instance and run it', async (toolsArg) => {
|
tap.test('should create a smarts3 instance and run it', async (toolsArg) => {
|
||||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||||
port: 3000,
|
server: {
|
||||||
cleanSlate: true,
|
port: 3333,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
cleanSlate: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
console.log(`Let the instance run for 2 seconds`);
|
console.log(`Let the instance run for 2 seconds`);
|
||||||
await toolsArg.delayFor(2000);
|
await toolsArg.delayFor(2000);
|
||||||
@@ -22,7 +26,7 @@ tap.test('should be able to access buckets', async () => {
|
|||||||
const baseDirectory = await bucket.getBaseDirectory();
|
const baseDirectory = await bucket.getBaseDirectory();
|
||||||
await baseDirectory.fastPut({
|
await baseDirectory.fastPut({
|
||||||
path: 'subdir/hello.txt',
|
path: 'subdir/hello.txt',
|
||||||
contents: 'hi there!'
|
contents: 'hi there!',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smarts3',
|
name: '@push.rocks/smarts3',
|
||||||
version: '2.2.3',
|
version: '5.0.2',
|
||||||
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
118
ts/classes/context.ts
Normal file
118
ts/classes/context.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { S3Error } from './s3-error.js';
|
||||||
|
import { createXml } from '../utils/xml.utils.js';
|
||||||
|
import type { FilesystemStore } from './filesystem-store.js';
|
||||||
|
import type { MultipartUploadManager } from './multipart-manager.js';
|
||||||
|
import type { Readable } from 'stream';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 request context with helper methods
|
||||||
|
*/
|
||||||
|
export class S3Context {
|
||||||
|
public method: string;
|
||||||
|
public url: URL;
|
||||||
|
public headers: plugins.http.IncomingHttpHeaders;
|
||||||
|
public params: Record<string, string> = {};
|
||||||
|
public query: Record<string, string> = {};
|
||||||
|
public store: FilesystemStore;
|
||||||
|
public multipart: MultipartUploadManager;
|
||||||
|
|
||||||
|
private req: plugins.http.IncomingMessage;
|
||||||
|
private res: plugins.http.ServerResponse;
|
||||||
|
private statusCode: number = 200;
|
||||||
|
private responseHeaders: Record<string, string> = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
store: FilesystemStore,
|
||||||
|
multipart: MultipartUploadManager
|
||||||
|
) {
|
||||||
|
this.req = req;
|
||||||
|
this.res = res;
|
||||||
|
this.store = store;
|
||||||
|
this.multipart = multipart;
|
||||||
|
this.method = req.method || 'GET';
|
||||||
|
this.headers = req.headers;
|
||||||
|
|
||||||
|
// Parse URL and query string
|
||||||
|
const fullUrl = `http://${req.headers.host || 'localhost'}${req.url || '/'}`;
|
||||||
|
this.url = new URL(fullUrl);
|
||||||
|
|
||||||
|
// Parse query string into object
|
||||||
|
this.url.searchParams.forEach((value, key) => {
|
||||||
|
this.query[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set response status code
|
||||||
|
*/
|
||||||
|
public status(code: number): this {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set response header
|
||||||
|
*/
|
||||||
|
public setHeader(name: string, value: string | number): this {
|
||||||
|
this.responseHeaders[name] = value.toString();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send response body (string, Buffer, or Stream)
|
||||||
|
*/
|
||||||
|
public async send(body: string | Buffer | Readable | NodeJS.ReadableStream): Promise<void> {
|
||||||
|
// Write status and headers
|
||||||
|
this.res.writeHead(this.statusCode, this.responseHeaders);
|
||||||
|
|
||||||
|
// Handle different body types
|
||||||
|
if (typeof body === 'string' || body instanceof Buffer) {
|
||||||
|
this.res.end(body);
|
||||||
|
} else if (body && typeof (body as any).pipe === 'function') {
|
||||||
|
// It's a stream
|
||||||
|
(body as Readable).pipe(this.res);
|
||||||
|
} else {
|
||||||
|
this.res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send XML response
|
||||||
|
*/
|
||||||
|
public async sendXML(obj: any): Promise<void> {
|
||||||
|
const xml = createXml(obj, { format: true });
|
||||||
|
this.setHeader('Content-Type', 'application/xml');
|
||||||
|
this.setHeader('Content-Length', Buffer.byteLength(xml));
|
||||||
|
await this.send(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw an S3 error
|
||||||
|
*/
|
||||||
|
public throw(code: string, message: string, detail?: Record<string, any>): never {
|
||||||
|
throw new S3Error(code, message, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse request body as string
|
||||||
|
*/
|
||||||
|
public async readBody(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
this.req.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
this.req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
this.req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the request stream (for streaming uploads)
|
||||||
|
*/
|
||||||
|
public getRequestStream(): NodeJS.ReadableStream {
|
||||||
|
return this.req;
|
||||||
|
}
|
||||||
|
}
|
||||||
562
ts/classes/filesystem-store.ts
Normal file
562
ts/classes/filesystem-store.ts
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { S3Error } from './s3-error.js';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
export interface IS3Bucket {
|
||||||
|
name: string;
|
||||||
|
creationDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IS3Object {
|
||||||
|
key: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: Date;
|
||||||
|
md5: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
content?: Readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IListObjectsOptions {
|
||||||
|
prefix?: string;
|
||||||
|
delimiter?: string;
|
||||||
|
maxKeys?: number;
|
||||||
|
continuationToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IListObjectsResult {
|
||||||
|
contents: IS3Object[];
|
||||||
|
commonPrefixes: string[];
|
||||||
|
isTruncated: boolean;
|
||||||
|
nextContinuationToken?: string;
|
||||||
|
prefix: string;
|
||||||
|
delimiter: string;
|
||||||
|
maxKeys: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRangeOptions {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem-backed storage for S3 objects using smartfs
|
||||||
|
*/
|
||||||
|
export class FilesystemStore {
|
||||||
|
constructor(private rootDir: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize store (ensure root directory exists)
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await plugins.smartfs.directory(this.rootDir).recursive().create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset store (delete all buckets)
|
||||||
|
*/
|
||||||
|
public async reset(): Promise<void> {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// BUCKET OPERATIONS
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all buckets
|
||||||
|
*/
|
||||||
|
public async listBuckets(): Promise<IS3Bucket[]> {
|
||||||
|
const entries = await plugins.smartfs.directory(this.rootDir).includeStats().list();
|
||||||
|
const buckets: IS3Bucket[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory && entry.stats) {
|
||||||
|
buckets.push({
|
||||||
|
name: entry.name,
|
||||||
|
creationDate: entry.stats.birthtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if bucket exists
|
||||||
|
*/
|
||||||
|
public async bucketExists(bucket: string): Promise<boolean> {
|
||||||
|
const bucketPath = this.getBucketPath(bucket);
|
||||||
|
return plugins.smartfs.directory(bucketPath).exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create bucket
|
||||||
|
*/
|
||||||
|
public async createBucket(bucket: string): Promise<void> {
|
||||||
|
const bucketPath = this.getBucketPath(bucket);
|
||||||
|
await plugins.smartfs.directory(bucketPath).recursive().create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete bucket (must be empty)
|
||||||
|
*/
|
||||||
|
public async deleteBucket(bucket: string): Promise<void> {
|
||||||
|
const bucketPath = this.getBucketPath(bucket);
|
||||||
|
|
||||||
|
// Check if bucket exists
|
||||||
|
if (!(await this.bucketExists(bucket))) {
|
||||||
|
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bucket is empty
|
||||||
|
const files = await plugins.smartfs.directory(bucketPath).recursive().list();
|
||||||
|
if (files.length > 0) {
|
||||||
|
throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.smartfs.directory(bucketPath).recursive().delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// OBJECT OPERATIONS
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List objects in bucket
|
||||||
|
*/
|
||||||
|
public async listObjects(
|
||||||
|
bucket: string,
|
||||||
|
options: IListObjectsOptions = {}
|
||||||
|
): Promise<IListObjectsResult> {
|
||||||
|
const bucketPath = this.getBucketPath(bucket);
|
||||||
|
|
||||||
|
if (!(await this.bucketExists(bucket))) {
|
||||||
|
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
prefix = '',
|
||||||
|
delimiter = '',
|
||||||
|
maxKeys = 1000,
|
||||||
|
continuationToken,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// List all object files recursively with filter
|
||||||
|
const entries = await plugins.smartfs
|
||||||
|
.directory(bucketPath)
|
||||||
|
.recursive()
|
||||||
|
.filter((entry) => entry.name.endsWith('._S3_object'))
|
||||||
|
.list();
|
||||||
|
|
||||||
|
// Convert file paths to keys
|
||||||
|
let keys = entries.map((entry) => {
|
||||||
|
const relativePath = plugins.path.relative(bucketPath, entry.path);
|
||||||
|
const key = this.decodeKey(relativePath.replace(/\._S3_object$/, ''));
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply prefix filter
|
||||||
|
if (prefix) {
|
||||||
|
keys = keys.filter((key) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort keys
|
||||||
|
keys = keys.sort();
|
||||||
|
|
||||||
|
// Handle continuation token (simple implementation using key name)
|
||||||
|
if (continuationToken) {
|
||||||
|
const startIndex = keys.findIndex((key) => key > continuationToken);
|
||||||
|
if (startIndex > 0) {
|
||||||
|
keys = keys.slice(startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delimiter (common prefixes)
|
||||||
|
const commonPrefixes: Set<string> = new Set();
|
||||||
|
const contents: IS3Object[] = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (delimiter) {
|
||||||
|
// Find first delimiter after prefix
|
||||||
|
const remainingKey = key.slice(prefix.length);
|
||||||
|
const delimiterIndex = remainingKey.indexOf(delimiter);
|
||||||
|
|
||||||
|
if (delimiterIndex !== -1) {
|
||||||
|
// This key has a delimiter, add to common prefixes
|
||||||
|
const commonPrefix = prefix + remainingKey.slice(0, delimiterIndex + delimiter.length);
|
||||||
|
commonPrefixes.add(commonPrefix);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to contents (limited by maxKeys)
|
||||||
|
if (contents.length >= maxKeys) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const objectInfo = await this.getObjectInfo(bucket, key);
|
||||||
|
contents.push(objectInfo);
|
||||||
|
} catch (err) {
|
||||||
|
// Skip if object no longer exists
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTruncated = keys.length > contents.length + commonPrefixes.size;
|
||||||
|
const nextContinuationToken = isTruncated
|
||||||
|
? contents[contents.length - 1]?.key
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
commonPrefixes: Array.from(commonPrefixes).sort(),
|
||||||
|
isTruncated,
|
||||||
|
nextContinuationToken,
|
||||||
|
prefix,
|
||||||
|
delimiter,
|
||||||
|
maxKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get object info (without content)
|
||||||
|
*/
|
||||||
|
private async getObjectInfo(bucket: string, key: string): Promise<IS3Object> {
|
||||||
|
const objectPath = this.getObjectPath(bucket, key);
|
||||||
|
const metadataPath = `${objectPath}.metadata.json`;
|
||||||
|
const md5Path = `${objectPath}.md5`;
|
||||||
|
|
||||||
|
const [stats, metadata, md5] = await Promise.all([
|
||||||
|
plugins.smartfs.file(objectPath).stat(),
|
||||||
|
this.readMetadata(metadataPath),
|
||||||
|
this.readMD5(objectPath, md5Path),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
size: stats.size,
|
||||||
|
lastModified: stats.mtime,
|
||||||
|
md5,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if object exists
|
||||||
|
*/
|
||||||
|
public async objectExists(bucket: string, key: string): Promise<boolean> {
|
||||||
|
const objectPath = this.getObjectPath(bucket, key);
|
||||||
|
return plugins.smartfs.file(objectPath).exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put object (upload with streaming)
|
||||||
|
*/
|
||||||
|
public async putObject(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
stream: NodeJS.ReadableStream,
|
||||||
|
metadata: Record<string, string> = {}
|
||||||
|
): Promise<{ size: number; md5: string }> {
|
||||||
|
const objectPath = this.getObjectPath(bucket, key);
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
if (!(await this.bucketExists(bucket))) {
|
||||||
|
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const parentDir = plugins.path.dirname(objectPath);
|
||||||
|
await plugins.smartfs.directory(parentDir).recursive().create();
|
||||||
|
|
||||||
|
// Write with MD5 calculation
|
||||||
|
const result = await this.writeStreamWithMD5(stream, objectPath);
|
||||||
|
|
||||||
|
// Save metadata
|
||||||
|
const metadataPath = `${objectPath}.metadata.json`;
|
||||||
|
await plugins.smartfs.file(metadataPath).write(JSON.stringify(metadata, null, 2));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get object (download with streaming)
|
||||||
|
*/
|
||||||
|
public async getObject(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
range?: IRangeOptions
|
||||||
|
): Promise<IS3Object> {
|
||||||
|
const objectPath = this.getObjectPath(bucket, key);
|
||||||
|
|
||||||
|
if (!(await this.objectExists(bucket, key))) {
|
||||||
|
throw new S3Error('NoSuchKey', 'The specified key does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await this.getObjectInfo(bucket, key);
|
||||||
|
|
||||||
|
// Get Web ReadableStream from smartfs
|
||||||
|
const webStream = await plugins.smartfs.file(objectPath).readStream();
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
...info,
|
||||||
|
content: nodeStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete object
|
||||||
|
*/
|
||||||
|
public async deleteObject(bucket: string, key: string): Promise<void> {
|
||||||
|
const objectPath = this.getObjectPath(bucket, key);
|
||||||
|
const metadataPath = `${objectPath}.metadata.json`;
|
||||||
|
const md5Path = `${objectPath}.md5`;
|
||||||
|
|
||||||
|
// S3 doesn't throw error if object doesn't exist
|
||||||
|
await Promise.all([
|
||||||
|
plugins.smartfs.file(objectPath).delete().catch(() => {}),
|
||||||
|
plugins.smartfs.file(metadataPath).delete().catch(() => {}),
|
||||||
|
plugins.smartfs.file(md5Path).delete().catch(() => {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy object
|
||||||
|
*/
|
||||||
|
public async copyObject(
|
||||||
|
srcBucket: string,
|
||||||
|
srcKey: string,
|
||||||
|
destBucket: string,
|
||||||
|
destKey: string,
|
||||||
|
metadataDirective: 'COPY' | 'REPLACE' = 'COPY',
|
||||||
|
newMetadata?: Record<string, string>
|
||||||
|
): Promise<{ size: number; md5: string }> {
|
||||||
|
const srcObjectPath = this.getObjectPath(srcBucket, srcKey);
|
||||||
|
const destObjectPath = this.getObjectPath(destBucket, destKey);
|
||||||
|
|
||||||
|
// Check source exists
|
||||||
|
if (!(await this.objectExists(srcBucket, srcKey))) {
|
||||||
|
throw new S3Error('NoSuchKey', 'The specified key does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dest bucket exists
|
||||||
|
if (!(await this.bucketExists(destBucket))) {
|
||||||
|
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const parentDir = plugins.path.dirname(destObjectPath);
|
||||||
|
await plugins.smartfs.directory(parentDir).recursive().create();
|
||||||
|
|
||||||
|
// Copy object file
|
||||||
|
await plugins.smartfs.file(srcObjectPath).copy(destObjectPath);
|
||||||
|
|
||||||
|
// Handle metadata
|
||||||
|
if (metadataDirective === 'COPY') {
|
||||||
|
// Copy metadata
|
||||||
|
const srcMetadataPath = `${srcObjectPath}.metadata.json`;
|
||||||
|
const destMetadataPath = `${destObjectPath}.metadata.json`;
|
||||||
|
await plugins.smartfs.file(srcMetadataPath).copy(destMetadataPath).catch(() => {});
|
||||||
|
} else if (newMetadata) {
|
||||||
|
// Replace with new metadata
|
||||||
|
const destMetadataPath = `${destObjectPath}.metadata.json`;
|
||||||
|
await plugins.smartfs.file(destMetadataPath).write(JSON.stringify(newMetadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy MD5
|
||||||
|
const srcMD5Path = `${srcObjectPath}.md5`;
|
||||||
|
const destMD5Path = `${destObjectPath}.md5`;
|
||||||
|
await plugins.smartfs.file(srcMD5Path).copy(destMD5Path).catch(() => {});
|
||||||
|
|
||||||
|
// Get result info
|
||||||
|
const stats = await plugins.smartfs.file(destObjectPath).stat();
|
||||||
|
const md5 = await this.readMD5(destObjectPath, destMD5Path);
|
||||||
|
|
||||||
|
return { size: stats.size, md5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bucket directory path
|
||||||
|
*/
|
||||||
|
private getBucketPath(bucket: string): string {
|
||||||
|
return plugins.path.join(this.rootDir, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get object file path
|
||||||
|
*/
|
||||||
|
private getObjectPath(bucket: string, key: string): string {
|
||||||
|
return plugins.path.join(
|
||||||
|
this.rootDir,
|
||||||
|
bucket,
|
||||||
|
this.encodeKey(key) + '._S3_object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode key for Windows compatibility
|
||||||
|
*/
|
||||||
|
private encodeKey(key: string): string {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Replace invalid Windows filename chars with hex encoding
|
||||||
|
return key.replace(/[<>:"\\|?*]/g, (ch) =>
|
||||||
|
'&' + Buffer.from(ch, 'utf8').toString('hex')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode key from filesystem path
|
||||||
|
*/
|
||||||
|
private decodeKey(encodedKey: string): string {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Decode hex-encoded chars
|
||||||
|
return encodedKey.replace(/&([0-9a-f]{2})/gi, (_, hex) =>
|
||||||
|
Buffer.from(hex, 'hex').toString('utf8')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return encodedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write stream to file with MD5 calculation
|
||||||
|
*/
|
||||||
|
private async writeStreamWithMD5(
|
||||||
|
input: NodeJS.ReadableStream,
|
||||||
|
destPath: string
|
||||||
|
): Promise<{ size: number; md5: string }> {
|
||||||
|
const hash = plugins.crypto.createHash('md5');
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
// Get Web WritableStream from smartfs
|
||||||
|
const webWriteStream = await plugins.smartfs.file(destPath).writeStream();
|
||||||
|
const writer = webWriteStream.getWriter();
|
||||||
|
|
||||||
|
// Read from Node.js stream and write to Web stream
|
||||||
|
input.on('data', async (chunk: Buffer) => {
|
||||||
|
hash.update(chunk);
|
||||||
|
totalSize += chunk.length;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writer.write(new Uint8Array(chunk));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on('error', (err) => {
|
||||||
|
writer.abort(err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on('end', async () => {
|
||||||
|
try {
|
||||||
|
await writer.close();
|
||||||
|
const md5 = hash.digest('hex');
|
||||||
|
|
||||||
|
// Save MD5 to separate file
|
||||||
|
const md5Path = `${destPath}.md5`;
|
||||||
|
await plugins.smartfs.file(md5Path).write(md5);
|
||||||
|
|
||||||
|
resolve({ size: totalSize, md5 });
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read MD5 hash (calculate if missing)
|
||||||
|
*/
|
||||||
|
private async readMD5(objectPath: string, md5Path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Try to read cached MD5
|
||||||
|
const md5 = await plugins.smartfs.file(md5Path).encoding('utf8').read() as string;
|
||||||
|
return md5.trim();
|
||||||
|
} catch (err) {
|
||||||
|
// Calculate MD5 if not cached
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const hash = plugins.crypto.createHash('md5');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webStream = await plugins.smartfs.file(objectPath).readStream();
|
||||||
|
const nodeStream = Readable.fromWeb(webStream as any);
|
||||||
|
|
||||||
|
nodeStream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||||
|
nodeStream.on('end', async () => {
|
||||||
|
const md5 = hash.digest('hex');
|
||||||
|
// Cache it
|
||||||
|
await plugins.smartfs.file(md5Path).write(md5);
|
||||||
|
resolve(md5);
|
||||||
|
});
|
||||||
|
nodeStream.on('error', reject);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read metadata from JSON file
|
||||||
|
*/
|
||||||
|
private async readMetadata(metadataPath: string): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const content = await plugins.smartfs.file(metadataPath).encoding('utf8').read() as string;
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ts/classes/logger.ts
Normal file
130
ts/classes/logger.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ts/classes/middleware-stack.ts
Normal file
43
ts/classes/middleware-stack.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { S3Context } from './context.js';
|
||||||
|
|
||||||
|
export type Middleware = (
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
next: () => Promise<void>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware stack for composing request handlers
|
||||||
|
*/
|
||||||
|
export class MiddlewareStack {
|
||||||
|
private middlewares: Middleware[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add middleware to the stack
|
||||||
|
*/
|
||||||
|
public use(middleware: Middleware): void {
|
||||||
|
this.middlewares.push(middleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all middlewares in order
|
||||||
|
*/
|
||||||
|
public async execute(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context
|
||||||
|
): Promise<void> {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const next = async (): Promise<void> => {
|
||||||
|
if (index < this.middlewares.length) {
|
||||||
|
const middleware = this.middlewares[index++];
|
||||||
|
await middleware(req, res, ctx, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
238
ts/classes/multipart-manager.ts
Normal file
238
ts/classes/multipart-manager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
ts/classes/router.ts
Normal file
129
ts/classes/router.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { S3Context } from './context.js';
|
||||||
|
|
||||||
|
export type RouteHandler = (
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
export interface IRouteMatch {
|
||||||
|
handler: RouteHandler;
|
||||||
|
params: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRoute {
|
||||||
|
method: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
paramNames: string[];
|
||||||
|
handler: RouteHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple HTTP router with pattern matching for S3 routes
|
||||||
|
*/
|
||||||
|
export class S3Router {
|
||||||
|
private routes: IRoute[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a route with pattern matching
|
||||||
|
* Supports patterns like:
|
||||||
|
* - "/" (exact match)
|
||||||
|
* - "/:bucket" (single param)
|
||||||
|
* - "/:bucket/:key*" (param with wildcard - captures everything after)
|
||||||
|
*/
|
||||||
|
public add(method: string, pattern: string, handler: RouteHandler): void {
|
||||||
|
const { regex, paramNames } = this.convertPatternToRegex(pattern);
|
||||||
|
|
||||||
|
this.routes.push({
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
pattern: regex,
|
||||||
|
paramNames,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a request to a route
|
||||||
|
*/
|
||||||
|
public match(method: string, pathname: string): IRouteMatch | null {
|
||||||
|
// Normalize pathname: remove trailing slash unless it's root
|
||||||
|
const normalizedPath = pathname === '/' ? pathname : pathname.replace(/\/$/, '');
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (route.method !== method.toUpperCase()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = normalizedPath.match(route.pattern);
|
||||||
|
if (match) {
|
||||||
|
// Extract params from captured groups
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < route.paramNames.length; i++) {
|
||||||
|
params[route.paramNames[i]] = decodeURIComponent(match[i + 1] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handler: route.handler,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert path pattern to RegExp
|
||||||
|
* Examples:
|
||||||
|
* - "/" → /^\/$/
|
||||||
|
* - "/:bucket" → /^\/([^/]+)$/
|
||||||
|
* - "/:bucket/:key*" → /^\/([^/]+)\/(.+)$/
|
||||||
|
*/
|
||||||
|
private convertPatternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
|
||||||
|
const paramNames: string[] = [];
|
||||||
|
let regexStr = pattern;
|
||||||
|
|
||||||
|
// Process all params in a single pass to maintain order
|
||||||
|
regexStr = regexStr.replace(/:(\w+)(\*)?/g, (match, paramName, isWildcard) => {
|
||||||
|
paramNames.push(paramName);
|
||||||
|
// :param* captures rest of path, :param captures single segment
|
||||||
|
return isWildcard ? '(.+)' : '([^/]+)';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape special regex characters
|
||||||
|
regexStr = regexStr.replace(/\//g, '\\/');
|
||||||
|
|
||||||
|
// Add anchors
|
||||||
|
regexStr = `^${regexStr}$`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
regex: new RegExp(regexStr),
|
||||||
|
paramNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience methods for common HTTP methods
|
||||||
|
*/
|
||||||
|
public get(pattern: string, handler: RouteHandler): void {
|
||||||
|
this.add('GET', pattern, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public put(pattern: string, handler: RouteHandler): void {
|
||||||
|
this.add('PUT', pattern, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public post(pattern: string, handler: RouteHandler): void {
|
||||||
|
this.add('POST', pattern, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(pattern: string, handler: RouteHandler): void {
|
||||||
|
this.add('DELETE', pattern, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public head(pattern: string, handler: RouteHandler): void {
|
||||||
|
this.add('HEAD', pattern, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
ts/classes/s3-error.ts
Normal file
145
ts/classes/s3-error.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 error codes mapped to HTTP status codes
|
||||||
|
*/
|
||||||
|
const S3_ERROR_CODES: Record<string, number> = {
|
||||||
|
'AccessDenied': 403,
|
||||||
|
'BadDigest': 400,
|
||||||
|
'BadRequest': 400,
|
||||||
|
'BucketAlreadyExists': 409,
|
||||||
|
'BucketAlreadyOwnedByYou': 409,
|
||||||
|
'BucketNotEmpty': 409,
|
||||||
|
'CredentialsNotSupported': 400,
|
||||||
|
'EntityTooSmall': 400,
|
||||||
|
'EntityTooLarge': 400,
|
||||||
|
'ExpiredToken': 400,
|
||||||
|
'IncompleteBody': 400,
|
||||||
|
'IncorrectNumberOfFilesInPostRequest': 400,
|
||||||
|
'InlineDataTooLarge': 400,
|
||||||
|
'InternalError': 500,
|
||||||
|
'InvalidArgument': 400,
|
||||||
|
'InvalidBucketName': 400,
|
||||||
|
'InvalidDigest': 400,
|
||||||
|
'InvalidLocationConstraint': 400,
|
||||||
|
'InvalidPart': 400,
|
||||||
|
'InvalidPartOrder': 400,
|
||||||
|
'InvalidRange': 416,
|
||||||
|
'InvalidRequest': 400,
|
||||||
|
'InvalidSecurity': 403,
|
||||||
|
'InvalidSOAPRequest': 400,
|
||||||
|
'InvalidStorageClass': 400,
|
||||||
|
'InvalidTargetBucketForLogging': 400,
|
||||||
|
'InvalidToken': 400,
|
||||||
|
'InvalidURI': 400,
|
||||||
|
'KeyTooLongError': 400,
|
||||||
|
'MalformedACLError': 400,
|
||||||
|
'MalformedPOSTRequest': 400,
|
||||||
|
'MalformedXML': 400,
|
||||||
|
'MaxMessageLengthExceeded': 400,
|
||||||
|
'MaxPostPreDataLengthExceededError': 400,
|
||||||
|
'MetadataTooLarge': 400,
|
||||||
|
'MethodNotAllowed': 405,
|
||||||
|
'MissingContentLength': 411,
|
||||||
|
'MissingRequestBodyError': 400,
|
||||||
|
'MissingSecurityElement': 400,
|
||||||
|
'MissingSecurityHeader': 400,
|
||||||
|
'NoLoggingStatusForKey': 400,
|
||||||
|
'NoSuchBucket': 404,
|
||||||
|
'NoSuchKey': 404,
|
||||||
|
'NoSuchLifecycleConfiguration': 404,
|
||||||
|
'NoSuchUpload': 404,
|
||||||
|
'NoSuchVersion': 404,
|
||||||
|
'NotImplemented': 501,
|
||||||
|
'NotSignedUp': 403,
|
||||||
|
'OperationAborted': 409,
|
||||||
|
'PermanentRedirect': 301,
|
||||||
|
'PreconditionFailed': 412,
|
||||||
|
'Redirect': 307,
|
||||||
|
'RequestIsNotMultiPartContent': 400,
|
||||||
|
'RequestTimeout': 400,
|
||||||
|
'RequestTimeTooSkewed': 403,
|
||||||
|
'RequestTorrentOfBucketError': 400,
|
||||||
|
'SignatureDoesNotMatch': 403,
|
||||||
|
'ServiceUnavailable': 503,
|
||||||
|
'SlowDown': 503,
|
||||||
|
'TemporaryRedirect': 307,
|
||||||
|
'TokenRefreshRequired': 400,
|
||||||
|
'TooManyBuckets': 400,
|
||||||
|
'UnexpectedContent': 400,
|
||||||
|
'UnresolvableGrantByEmailAddress': 400,
|
||||||
|
'UserKeyMustBeSpecified': 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-compatible error class that formats errors as XML responses
|
||||||
|
*/
|
||||||
|
export class S3Error extends Error {
|
||||||
|
public status: number;
|
||||||
|
public code: string;
|
||||||
|
public detail: Record<string, any>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
detail: Record<string, any> = {}
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'S3Error';
|
||||||
|
this.code = code;
|
||||||
|
this.status = S3_ERROR_CODES[code] || 500;
|
||||||
|
this.detail = detail;
|
||||||
|
|
||||||
|
// Maintain proper stack trace
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, S3Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert error to S3-compatible XML format
|
||||||
|
*/
|
||||||
|
public toXML(): string {
|
||||||
|
const smartXmlInstance = new plugins.SmartXml();
|
||||||
|
const errorObj: any = {
|
||||||
|
Error: {
|
||||||
|
Code: this.code,
|
||||||
|
Message: this.message,
|
||||||
|
...this.detail,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const xml = smartXmlInstance.createXmlFromObject(errorObj);
|
||||||
|
|
||||||
|
// Ensure XML declaration
|
||||||
|
if (!xml.startsWith('<?xml')) {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n${xml}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create S3Error from a generic Error
|
||||||
|
*/
|
||||||
|
public static fromError(err: any): S3Error {
|
||||||
|
if (err instanceof S3Error) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map common errors
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
return new S3Error('NoSuchKey', 'The specified key does not exist.');
|
||||||
|
}
|
||||||
|
if (err.code === 'EACCES') {
|
||||||
|
return new S3Error('AccessDenied', 'Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to internal error
|
||||||
|
return new S3Error(
|
||||||
|
'InternalError',
|
||||||
|
'We encountered an internal error. Please try again.',
|
||||||
|
{ OriginalError: err.message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
388
ts/classes/smarts3-server.ts
Normal file
388
ts/classes/smarts3-server.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { S3Router } from './router.js';
|
||||||
|
import { MiddlewareStack } from './middleware-stack.js';
|
||||||
|
import { S3Context } from './context.js';
|
||||||
|
import { FilesystemStore } from './filesystem-store.js';
|
||||||
|
import { S3Error } from './s3-error.js';
|
||||||
|
import { Logger } from './logger.js';
|
||||||
|
import { MultipartUploadManager } from './multipart-manager.js';
|
||||||
|
import { ServiceController } from '../controllers/service.controller.js';
|
||||||
|
import { BucketController } from '../controllers/bucket.controller.js';
|
||||||
|
import { ObjectController } from '../controllers/object.controller.js';
|
||||||
|
import type { ISmarts3Config } from '../index.js';
|
||||||
|
|
||||||
|
export interface ISmarts3ServerOptions {
|
||||||
|
port?: number;
|
||||||
|
address?: string;
|
||||||
|
directory?: string;
|
||||||
|
cleanSlate?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
|
config?: Required<ISmarts3Config>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom S3-compatible server implementation
|
||||||
|
* Built on native Node.js http module with zero framework dependencies
|
||||||
|
*/
|
||||||
|
export class Smarts3Server {
|
||||||
|
private httpServer?: plugins.http.Server;
|
||||||
|
private router: S3Router;
|
||||||
|
private middlewares: MiddlewareStack;
|
||||||
|
public store: FilesystemStore; // Made public for direct access from Smarts3 class
|
||||||
|
public multipart: MultipartUploadManager; // Made public for controller access
|
||||||
|
private options: Required<Omit<ISmarts3ServerOptions, 'config'>>;
|
||||||
|
private config: Required<ISmarts3Config>;
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
constructor(options: ISmarts3ServerOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
port: options.port ?? 3000,
|
||||||
|
address: options.address ?? '0.0.0.0',
|
||||||
|
directory: options.directory ?? plugins.path.join(process.cwd(), '.nogit/bucketsDir'),
|
||||||
|
cleanSlate: options.cleanSlate ?? false,
|
||||||
|
silent: options.silent ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.multipart = new MultipartUploadManager(this.options.directory);
|
||||||
|
this.router = new S3Router();
|
||||||
|
this.middlewares = new MiddlewareStack();
|
||||||
|
|
||||||
|
this.setupMiddlewares();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup middleware stack
|
||||||
|
*/
|
||||||
|
private setupMiddlewares(): void {
|
||||||
|
// CORS middleware (must be first to handle preflight requests)
|
||||||
|
if (this.config.cors.enabled) {
|
||||||
|
this.middlewares.use(async (req, res, ctx, next) => {
|
||||||
|
const origin = req.headers.origin || req.headers.referer;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication middleware (simple static credentials)
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup routes
|
||||||
|
*/
|
||||||
|
private setupRoutes(): void {
|
||||||
|
// Service level (/)
|
||||||
|
this.router.get('/', ServiceController.listBuckets);
|
||||||
|
|
||||||
|
// Bucket level (/:bucket)
|
||||||
|
this.router.put('/:bucket', BucketController.createBucket);
|
||||||
|
this.router.delete('/:bucket', BucketController.deleteBucket);
|
||||||
|
this.router.get('/:bucket', BucketController.listObjects);
|
||||||
|
this.router.head('/:bucket', BucketController.headBucket);
|
||||||
|
|
||||||
|
// Object level (/:bucket/:key*)
|
||||||
|
this.router.put('/:bucket/:key*', ObjectController.putObject);
|
||||||
|
this.router.post('/:bucket/:key*', ObjectController.postObject); // For multipart operations
|
||||||
|
this.router.get('/:bucket/:key*', ObjectController.getObject);
|
||||||
|
this.router.head('/:bucket/:key*', ObjectController.headObject);
|
||||||
|
this.router.delete('/:bucket/:key*', ObjectController.deleteObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming HTTP request
|
||||||
|
*/
|
||||||
|
private async handleRequest(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse
|
||||||
|
): Promise<void> {
|
||||||
|
const context = new S3Context(req, res, this.store, this.multipart);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute middleware stack
|
||||||
|
await this.middlewares.execute(req, res, context);
|
||||||
|
|
||||||
|
// Route to handler
|
||||||
|
const match = this.router.match(context.method, context.url.pathname);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
context.params = match.params;
|
||||||
|
await match.handler(req, res, context, match.params);
|
||||||
|
} else {
|
||||||
|
context.throw('NoSuchKey', 'The specified resource does not exist');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await this.handleError(err, context, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors and send S3-compatible error responses
|
||||||
|
*/
|
||||||
|
private async handleError(
|
||||||
|
err: any,
|
||||||
|
context: S3Context,
|
||||||
|
res: plugins.http.ServerResponse
|
||||||
|
): Promise<void> {
|
||||||
|
const s3Error = err instanceof S3Error ? err : S3Error.fromError(err);
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
this.logger.s3Error(s3Error.code, s3Error.message, s3Error.status);
|
||||||
|
|
||||||
|
// Log stack trace for server errors
|
||||||
|
if (s3Error.status >= 500) {
|
||||||
|
this.logger.debug('Error stack trace', {
|
||||||
|
stack: err.stack || err.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error response
|
||||||
|
const errorXml = s3Error.toXML();
|
||||||
|
|
||||||
|
res.writeHead(s3Error.status, {
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
'Content-Length': Buffer.byteLength(errorXml),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end(errorXml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
// Initialize store
|
||||||
|
await this.store.initialize();
|
||||||
|
|
||||||
|
// Initialize multipart upload manager
|
||||||
|
await this.multipart.initialize();
|
||||||
|
|
||||||
|
// Clean slate if requested
|
||||||
|
if (this.options.cleanSlate) {
|
||||||
|
await this.store.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
this.httpServer = plugins.http.createServer((req, res) => {
|
||||||
|
this.handleRequest(req, res).catch((err) => {
|
||||||
|
this.logger.error('Fatal error in request handler', {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start listening
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.httpServer!.listen(this.options.port, this.options.address, (err?: Error) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
this.logger.info(`S3 server listening on ${this.options.address}:${this.options.port}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.httpServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.httpServer!.close((err?: Error) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
this.logger.info('S3 server stopped');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.httpServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server port (useful for testing with random ports)
|
||||||
|
*/
|
||||||
|
public getPort(): number {
|
||||||
|
if (!this.httpServer) {
|
||||||
|
throw new Error('Server not started');
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.httpServer.address();
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
throw new Error('Unix socket not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
return address?.port || this.options.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get S3 descriptor for client configuration
|
||||||
|
*/
|
||||||
|
public getS3Descriptor(): {
|
||||||
|
accessKey: string;
|
||||||
|
accessSecret: string;
|
||||||
|
endpoint: string;
|
||||||
|
port: number;
|
||||||
|
useSsl: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
accessKey: 'S3RVER',
|
||||||
|
accessSecret: 'S3RVER',
|
||||||
|
endpoint: this.options.address === '0.0.0.0' ? '127.0.0.1' : this.options.address,
|
||||||
|
port: this.getPort(),
|
||||||
|
useSsl: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ts/controllers/bucket.controller.ts
Normal file
130
ts/controllers/bucket.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { S3Context } from '../classes/context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket-level operations
|
||||||
|
*/
|
||||||
|
export class BucketController {
|
||||||
|
/**
|
||||||
|
* HEAD /:bucket - Check if bucket exists
|
||||||
|
*/
|
||||||
|
public static async headBucket(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket } = params;
|
||||||
|
|
||||||
|
if (await ctx.store.bucketExists(bucket)) {
|
||||||
|
ctx.status(200).send('');
|
||||||
|
} else {
|
||||||
|
ctx.throw('NoSuchBucket', 'The specified bucket does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /:bucket - Create bucket
|
||||||
|
*/
|
||||||
|
public static async createBucket(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket } = params;
|
||||||
|
|
||||||
|
await ctx.store.createBucket(bucket);
|
||||||
|
ctx.status(200).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:bucket - Delete bucket
|
||||||
|
*/
|
||||||
|
public static async deleteBucket(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket } = params;
|
||||||
|
|
||||||
|
await ctx.store.deleteBucket(bucket);
|
||||||
|
ctx.status(204).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:bucket - List objects
|
||||||
|
* Supports both V1 and V2 listing (V2 uses list-type=2 query param)
|
||||||
|
*/
|
||||||
|
public static async listObjects(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket } = params;
|
||||||
|
const isV2 = ctx.query['list-type'] === '2';
|
||||||
|
|
||||||
|
const result = await ctx.store.listObjects(bucket, {
|
||||||
|
prefix: ctx.query.prefix,
|
||||||
|
delimiter: ctx.query.delimiter,
|
||||||
|
maxKeys: ctx.query['max-keys'] ? parseInt(ctx.query['max-keys']) : 1000,
|
||||||
|
continuationToken: ctx.query['continuation-token'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
// List Objects V2 response
|
||||||
|
await ctx.sendXML({
|
||||||
|
ListBucketResult: {
|
||||||
|
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||||
|
Name: bucket,
|
||||||
|
Prefix: result.prefix || '',
|
||||||
|
MaxKeys: result.maxKeys,
|
||||||
|
KeyCount: result.contents.length,
|
||||||
|
IsTruncated: result.isTruncated,
|
||||||
|
...(result.delimiter && { Delimiter: result.delimiter }),
|
||||||
|
...(result.nextContinuationToken && {
|
||||||
|
NextContinuationToken: result.nextContinuationToken,
|
||||||
|
}),
|
||||||
|
...(result.commonPrefixes.length > 0 && {
|
||||||
|
CommonPrefixes: result.commonPrefixes.map((prefix) => ({
|
||||||
|
Prefix: prefix,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
Contents: result.contents.map((obj) => ({
|
||||||
|
Key: obj.key,
|
||||||
|
LastModified: obj.lastModified.toISOString(),
|
||||||
|
ETag: `"${obj.md5}"`,
|
||||||
|
Size: obj.size,
|
||||||
|
StorageClass: 'STANDARD',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// List Objects V1 response
|
||||||
|
await ctx.sendXML({
|
||||||
|
ListBucketResult: {
|
||||||
|
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||||
|
Name: bucket,
|
||||||
|
Prefix: result.prefix || '',
|
||||||
|
MaxKeys: result.maxKeys,
|
||||||
|
IsTruncated: result.isTruncated,
|
||||||
|
...(result.delimiter && { Delimiter: result.delimiter }),
|
||||||
|
...(result.commonPrefixes.length > 0 && {
|
||||||
|
CommonPrefixes: result.commonPrefixes.map((prefix) => ({
|
||||||
|
Prefix: prefix,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
Contents: result.contents.map((obj) => ({
|
||||||
|
Key: obj.key,
|
||||||
|
LastModified: obj.lastModified.toISOString(),
|
||||||
|
ETag: `"${obj.md5}"`,
|
||||||
|
Size: obj.size,
|
||||||
|
StorageClass: 'STANDARD',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
378
ts/controllers/object.controller.ts
Normal file
378
ts/controllers/object.controller.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { S3Context } from '../classes/context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object-level operations
|
||||||
|
*/
|
||||||
|
export class ObjectController {
|
||||||
|
/**
|
||||||
|
* PUT /:bucket/:key* - Upload object, copy object, or upload part
|
||||||
|
*/
|
||||||
|
public static async putObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
|
||||||
|
// Check if this is a multipart upload part
|
||||||
|
if (ctx.query.partNumber && ctx.query.uploadId) {
|
||||||
|
return ObjectController.uploadPart(req, res, ctx, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a COPY operation
|
||||||
|
const copySource = ctx.headers['x-amz-copy-source'] as string | undefined;
|
||||||
|
if (copySource) {
|
||||||
|
return ObjectController.copyObject(req, res, ctx, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from headers
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
for (const [header, value] of Object.entries(ctx.headers)) {
|
||||||
|
if (header.startsWith('x-amz-meta-')) {
|
||||||
|
metadata[header] = value as string;
|
||||||
|
}
|
||||||
|
if (header === 'content-type' && value) {
|
||||||
|
metadata['content-type'] = value as string;
|
||||||
|
}
|
||||||
|
if (header === 'cache-control' && value) {
|
||||||
|
metadata['cache-control'] = value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no content-type, default to binary/octet-stream
|
||||||
|
if (!metadata['content-type']) {
|
||||||
|
metadata['content-type'] = 'binary/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream upload
|
||||||
|
const result = await ctx.store.putObject(bucket, key, ctx.getRequestStream(), metadata);
|
||||||
|
|
||||||
|
ctx.setHeader('ETag', `"${result.md5}"`);
|
||||||
|
ctx.status(200).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:bucket/:key* - Download object
|
||||||
|
*/
|
||||||
|
public static async getObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
|
||||||
|
// Parse Range header if present
|
||||||
|
const rangeHeader = ctx.headers.range as string | undefined;
|
||||||
|
let range: { start: number; end: number } | undefined;
|
||||||
|
|
||||||
|
if (rangeHeader) {
|
||||||
|
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||||
|
if (matches) {
|
||||||
|
const start = parseInt(matches[1]);
|
||||||
|
const end = matches[2] ? parseInt(matches[2]) : undefined;
|
||||||
|
range = { start, end: end || start + 1024 * 1024 }; // Default to 1MB if no end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get object
|
||||||
|
const object = await ctx.store.getObject(bucket, key, range);
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
ctx.setHeader('ETag', `"${object.md5}"`);
|
||||||
|
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
||||||
|
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
||||||
|
ctx.setHeader('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
// Handle custom metadata headers
|
||||||
|
for (const [key, value] of Object.entries(object.metadata)) {
|
||||||
|
if (key.startsWith('x-amz-meta-')) {
|
||||||
|
ctx.setHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
ctx.status(206);
|
||||||
|
ctx.setHeader('Content-Length', (range.end - range.start + 1).toString());
|
||||||
|
ctx.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${object.size}`);
|
||||||
|
} else {
|
||||||
|
ctx.status(200);
|
||||||
|
ctx.setHeader('Content-Length', object.size.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream response
|
||||||
|
await ctx.send(object.content!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /:bucket/:key* - Get object metadata
|
||||||
|
*/
|
||||||
|
public static async headObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
|
||||||
|
// Get object (without content)
|
||||||
|
const object = await ctx.store.getObject(bucket, key);
|
||||||
|
|
||||||
|
// Set response headers (same as GET but no body)
|
||||||
|
ctx.setHeader('ETag', `"${object.md5}"`);
|
||||||
|
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
||||||
|
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
||||||
|
ctx.setHeader('Content-Length', object.size.toString());
|
||||||
|
ctx.setHeader('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
// Handle custom metadata headers
|
||||||
|
for (const [key, value] of Object.entries(object.metadata)) {
|
||||||
|
if (key.startsWith('x-amz-meta-')) {
|
||||||
|
ctx.setHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.status(200).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /:bucket/:key* - Delete object or abort multipart upload
|
||||||
|
*/
|
||||||
|
public static async deleteObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
|
||||||
|
// Check if this is an abort multipart upload
|
||||||
|
if (ctx.query.uploadId) {
|
||||||
|
return ObjectController.abortMultipartUpload(req, res, ctx, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.store.deleteObject(bucket, key);
|
||||||
|
ctx.status(204).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COPY operation (PUT with x-amz-copy-source header)
|
||||||
|
*/
|
||||||
|
private static async copyObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket: destBucket, key: destKey } = params;
|
||||||
|
const copySource = ctx.headers['x-amz-copy-source'] as string;
|
||||||
|
|
||||||
|
// Parse source bucket and key from copy source
|
||||||
|
// Format: /bucket/key or bucket/key
|
||||||
|
const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource;
|
||||||
|
const firstSlash = sourcePath.indexOf('/');
|
||||||
|
const srcBucket = decodeURIComponent(sourcePath.slice(0, firstSlash));
|
||||||
|
const srcKey = decodeURIComponent(sourcePath.slice(firstSlash + 1));
|
||||||
|
|
||||||
|
// Get metadata directive (COPY or REPLACE)
|
||||||
|
const metadataDirective = (ctx.headers['x-amz-metadata-directive'] as string)?.toUpperCase() || 'COPY';
|
||||||
|
|
||||||
|
// Extract new metadata if REPLACE
|
||||||
|
let newMetadata: Record<string, string> | undefined;
|
||||||
|
if (metadataDirective === 'REPLACE') {
|
||||||
|
newMetadata = {};
|
||||||
|
for (const [header, value] of Object.entries(ctx.headers)) {
|
||||||
|
if (header.startsWith('x-amz-meta-')) {
|
||||||
|
newMetadata[header] = value as string;
|
||||||
|
}
|
||||||
|
if (header === 'content-type' && value) {
|
||||||
|
newMetadata['content-type'] = value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform copy
|
||||||
|
const result = await ctx.store.copyObject(
|
||||||
|
srcBucket,
|
||||||
|
srcKey,
|
||||||
|
destBucket,
|
||||||
|
destKey,
|
||||||
|
metadataDirective as 'COPY' | 'REPLACE',
|
||||||
|
newMetadata
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send XML response
|
||||||
|
await ctx.sendXML({
|
||||||
|
CopyObjectResult: {
|
||||||
|
LastModified: new Date().toISOString(),
|
||||||
|
ETag: `"${result.md5}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:bucket/:key* - Initiate or complete multipart upload
|
||||||
|
*/
|
||||||
|
public static async postObject(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if this is initiate multipart upload
|
||||||
|
if (ctx.query.uploads !== undefined) {
|
||||||
|
return ObjectController.initiateMultipartUpload(req, res, ctx, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is complete multipart upload
|
||||||
|
if (ctx.query.uploadId) {
|
||||||
|
return ObjectController.completeMultipartUpload(req, res, ctx, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.throw('InvalidRequest', 'Invalid POST request');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate Multipart Upload (POST with ?uploads)
|
||||||
|
*/
|
||||||
|
private static async initiateMultipartUpload(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
|
||||||
|
// Extract metadata from headers
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
for (const [header, value] of Object.entries(ctx.headers)) {
|
||||||
|
if (header.startsWith('x-amz-meta-')) {
|
||||||
|
metadata[header] = value as string;
|
||||||
|
}
|
||||||
|
if (header === 'content-type' && value) {
|
||||||
|
metadata['content-type'] = value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate upload
|
||||||
|
const uploadId = await ctx.multipart.initiateUpload(bucket, key, metadata);
|
||||||
|
|
||||||
|
// Send XML response
|
||||||
|
await ctx.sendXML({
|
||||||
|
InitiateMultipartUploadResult: {
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
UploadId: uploadId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload Part (PUT with ?partNumber&uploadId)
|
||||||
|
*/
|
||||||
|
private static async uploadPart(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const uploadId = ctx.query.uploadId!;
|
||||||
|
const partNumber = parseInt(ctx.query.partNumber!);
|
||||||
|
|
||||||
|
if (isNaN(partNumber) || partNumber < 1 || partNumber > 10000) {
|
||||||
|
ctx.throw('InvalidPartNumber', 'Part number must be between 1 and 10000');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the part
|
||||||
|
const partInfo = await ctx.multipart.uploadPart(
|
||||||
|
uploadId,
|
||||||
|
partNumber,
|
||||||
|
ctx.getRequestStream() as any as import('stream').Readable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set ETag header (part ETag)
|
||||||
|
ctx.setHeader('ETag', `"${partInfo.etag}"`);
|
||||||
|
ctx.status(200).send('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete Multipart Upload (POST with ?uploadId)
|
||||||
|
*/
|
||||||
|
private static async completeMultipartUpload(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const { bucket, key } = params;
|
||||||
|
const uploadId = ctx.query.uploadId!;
|
||||||
|
|
||||||
|
// Read and parse request body (XML with part list)
|
||||||
|
const body = await ctx.readBody();
|
||||||
|
|
||||||
|
// Parse XML to extract parts
|
||||||
|
// Expected format: <CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"etag"</ETag></Part>...</CompleteMultipartUpload>
|
||||||
|
const partMatches = body.matchAll(/<Part>.*?<PartNumber>(\d+)<\/PartNumber>.*?<ETag>(.*?)<\/ETag>.*?<\/Part>/gs);
|
||||||
|
const parts: Array<{ PartNumber: number; ETag: string }> = [];
|
||||||
|
|
||||||
|
for (const match of partMatches) {
|
||||||
|
parts.push({
|
||||||
|
PartNumber: parseInt(match[1]),
|
||||||
|
ETag: match[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the upload
|
||||||
|
const result = await ctx.multipart.completeUpload(uploadId, parts);
|
||||||
|
|
||||||
|
// Get upload metadata
|
||||||
|
const upload = ctx.multipart.getUpload(uploadId);
|
||||||
|
if (!upload) {
|
||||||
|
ctx.throw('NoSuchUpload', 'The specified upload does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move final file to object store
|
||||||
|
const finalPath = ctx.multipart.getFinalPath(uploadId);
|
||||||
|
const finalContent = await plugins.smartfs.file(finalPath).read();
|
||||||
|
const finalStream = plugins.http.IncomingMessage.prototype;
|
||||||
|
|
||||||
|
// Create a readable stream from the buffer
|
||||||
|
const { Readable } = await import('stream');
|
||||||
|
const finalReadableStream = Readable.from([finalContent]);
|
||||||
|
|
||||||
|
// Store the final object
|
||||||
|
await ctx.store.putObject(bucket, key, finalReadableStream, upload.metadata);
|
||||||
|
|
||||||
|
// Clean up multipart upload data
|
||||||
|
await ctx.multipart.cleanupUpload(uploadId);
|
||||||
|
|
||||||
|
// Send XML response
|
||||||
|
await ctx.sendXML({
|
||||||
|
CompleteMultipartUploadResult: {
|
||||||
|
Location: `/${bucket}/${key}`,
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
ETag: `"${result.etag}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort Multipart Upload (DELETE with ?uploadId)
|
||||||
|
*/
|
||||||
|
private static async abortMultipartUpload(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const uploadId = ctx.query.uploadId!;
|
||||||
|
|
||||||
|
// Abort and cleanup
|
||||||
|
await ctx.multipart.abortUpload(uploadId);
|
||||||
|
|
||||||
|
ctx.status(204).send('');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ts/controllers/service.controller.ts
Normal file
35
ts/controllers/service.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { S3Context } from '../classes/context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-level operations (root /)
|
||||||
|
*/
|
||||||
|
export class ServiceController {
|
||||||
|
/**
|
||||||
|
* GET / - List all buckets
|
||||||
|
*/
|
||||||
|
public static async listBuckets(
|
||||||
|
req: plugins.http.IncomingMessage,
|
||||||
|
res: plugins.http.ServerResponse,
|
||||||
|
ctx: S3Context,
|
||||||
|
params: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const buckets = await ctx.store.listBuckets();
|
||||||
|
|
||||||
|
await ctx.sendXML({
|
||||||
|
ListAllMyBucketsResult: {
|
||||||
|
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||||
|
Owner: {
|
||||||
|
ID: '123456789000',
|
||||||
|
DisplayName: 'S3rver',
|
||||||
|
},
|
||||||
|
Buckets: {
|
||||||
|
Bucket: buckets.map((bucket) => ({
|
||||||
|
Name: bucket.name,
|
||||||
|
CreationDate: bucket.creationDate.toISOString(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
210
ts/index.ts
210
ts/index.ts
@@ -1,64 +1,208 @@
|
|||||||
import * as plugins from './plugins.js';
|
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';
|
||||||
|
|
||||||
export interface ISmarts3ContructorOptions {
|
/**
|
||||||
|
* Authentication configuration
|
||||||
|
*/
|
||||||
|
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;
|
port?: number;
|
||||||
|
address?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage configuration
|
||||||
|
*/
|
||||||
|
export interface IStorageConfig {
|
||||||
|
directory?: string;
|
||||||
cleanSlate?: boolean;
|
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(optionsArg: ConstructorParameters<typeof Smarts3>[0]) {
|
public static async createAndStart(configArg: ISmarts3Config = {}) {
|
||||||
const smartS3Instance = new Smarts3(optionsArg);
|
const smartS3Instance = new Smarts3(configArg);
|
||||||
await smartS3Instance.start();
|
await smartS3Instance.start();
|
||||||
return smartS3Instance;
|
return smartS3Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
public options: ISmarts3ContructorOptions;
|
public config: Required<ISmarts3Config>;
|
||||||
public s3Instance: plugins.s3rver;
|
public s3Instance: Smarts3Server;
|
||||||
|
|
||||||
constructor(optionsArg: ISmarts3ContructorOptions) {
|
constructor(configArg: ISmarts3Config = {}) {
|
||||||
this.options = optionsArg;
|
this.config = mergeConfig(configArg);
|
||||||
this.options = {
|
|
||||||
...this.options,
|
|
||||||
...optionsArg,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
if (this.options.cleanSlate) {
|
this.s3Instance = new Smarts3Server({
|
||||||
await plugins.smartfile.fs.ensureEmptyDir(paths.bucketsDir);
|
port: this.config.server.port,
|
||||||
} else {
|
address: this.config.server.address,
|
||||||
await plugins.smartfile.fs.ensureDir(paths.bucketsDir);
|
directory: this.config.storage.directory,
|
||||||
}
|
cleanSlate: this.config.storage.cleanSlate,
|
||||||
this.s3Instance = new plugins.s3rver({
|
silent: this.config.server.silent,
|
||||||
port: this.options.port || 3000,
|
config: this.config, // Pass full config to server
|
||||||
address: '0.0.0.0',
|
|
||||||
silent: false,
|
|
||||||
directory: paths.bucketsDir,
|
|
||||||
});
|
});
|
||||||
await this.s3Instance.run();
|
await this.s3Instance.start();
|
||||||
console.log('s3 server is running');
|
|
||||||
|
if (!this.config.server.silent) {
|
||||||
|
console.log('s3 server is running');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getS3Descriptor(): Promise<plugins.tsclass.storage.IS3Descriptor> {
|
public async getS3Descriptor(
|
||||||
|
optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
|
||||||
|
): Promise<plugins.tsclass.storage.IS3Descriptor> {
|
||||||
|
const descriptor = this.s3Instance.getS3Descriptor();
|
||||||
return {
|
return {
|
||||||
accessKey: 'S3RVER',
|
...descriptor,
|
||||||
accessSecret: 'S3RVER',
|
...(optionsArg ? optionsArg : {}),
|
||||||
endpoint: '127.0.0.1',
|
|
||||||
port: this.options.port,
|
|
||||||
useSsl: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createBucket(bucketNameArg: string) {
|
public async createBucket(bucketNameArg: string) {
|
||||||
const smartbucketInstance = new plugins.smartbucket.SmartBucket(await this.getS3Descriptor());
|
// Call the filesystem store directly instead of using the client library
|
||||||
const bucket = await smartbucketInstance.createBucket(bucketNameArg);
|
await this.s3Instance.store.createBucket(bucketNameArg);
|
||||||
return bucket;
|
return { name: bucketNameArg };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.s3Instance.close();
|
await this.s3Instance.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the custom server class for direct use
|
||||||
|
export { Smarts3Server } from './classes/smarts3-server.js';
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
// node native
|
// node native
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as url from 'url';
|
||||||
|
|
||||||
export { path };
|
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';
|
||||||
|
|
||||||
export { smartbucket, smartfile, smartpath };
|
// 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 };
|
|
||||||
|
|||||||
39
ts/utils/xml.utils.ts
Normal file
39
ts/utils/xml.utils.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
// Create a singleton instance of SmartXml
|
||||||
|
const smartXmlInstance = new plugins.SmartXml();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse XML string to JavaScript object
|
||||||
|
*/
|
||||||
|
export function parseXml(xmlString: string): any {
|
||||||
|
return smartXmlInstance.parseXmlToObject(xmlString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert JavaScript object to XML string with XML declaration
|
||||||
|
*/
|
||||||
|
export function createXml(obj: any, options: { format?: boolean } = {}): string {
|
||||||
|
const xml = smartXmlInstance.createXmlFromObject(obj);
|
||||||
|
|
||||||
|
// Ensure XML declaration is present
|
||||||
|
if (!xml.startsWith('<?xml')) {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n${xml}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create S3-compatible XML responses with proper namespace
|
||||||
|
*/
|
||||||
|
export function createS3Xml(rootElement: string, content: any, namespace = 'http://s3.amazonaws.com/doc/2006-03-01/'): string {
|
||||||
|
const obj: any = {
|
||||||
|
[rootElement]: {
|
||||||
|
'@_xmlns': namespace,
|
||||||
|
...content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return createXml(obj, { format: true });
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["dist_*/**/*.d.ts"]
|
||||||
"dist_*/**/*.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user