16 Commits

Author SHA1 Message Date
654f47b7fc v2.3.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-21 14:32:19 +00:00
18a2eb7e3f 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. 2025-11-21 14:32:19 +00:00
3ab667049a v2.2.7
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-20 08:10:17 +00:00
871f0e0b78 fix(core): Update dependencies, code style and project config; add pnpm overrides and ignore AI folders 2025-11-20 08:10:17 +00:00
f2bf3f1314 2.2.6
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Successful in 53s
Default (tags) / release (push) Failing after 44s
Default (tags) / metadata (push) Successful in 54s
2025-08-16 16:22:15 +00:00
a3b40923a5 fix(Smarts3): Allow overriding S3 descriptor; update dependencies, test config and documentation 2025-08-16 16:22:15 +00:00
bdcfcee37a 2.2.5
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Successful in 1m54s
Default (tags) / release (push) Failing after 1m38s
Default (tags) / metadata (push) Successful in 2m13s
2024-11-06 17:03:10 +01:00
d41878721c fix(ci): Corrected docker image URLs in Gitea workflows to match the correct domain format. 2024-11-06 17:03:10 +01:00
3f69d55f5f 2.2.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2024-11-06 17:02:30 +01:00
ae015e433d fix(core): Improve code style and update dependencies 2024-11-06 17:02:29 +01:00
f86145f68b 2.2.3 2024-11-06 17:01:20 +01:00
2b908c3e4b fix(core): Fix endpoint address from localhost to 127.0.0.1 for better compatibility in Smarts3.getS3Descriptor 2024-11-06 17:01:20 +01:00
dfb77842d3 2.2.2 2024-11-06 03:11:52 +01:00
f13a99447d fix(core): Fixed function call for fastPut in the test suite to ensure proper file upload handling. 2024-11-06 03:11:51 +01:00
aac5a11a7a 2.2.1 2024-10-26 14:17:39 +02:00
36a1054b45 fix(core): Fix import and typings for improved compatibility 2024-10-26 14:17:38 +02:00
25 changed files with 9351 additions and 4978 deletions

View File

@@ -6,7 +6,7 @@ on:
- '**'
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_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}

View File

@@ -6,7 +6,7 @@ on:
- '*'
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_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}

7
.gitignore vendored
View File

@@ -3,7 +3,6 @@
# artifacts
coverage/
public/
pages/
# installs
node_modules/
@@ -17,4 +16,8 @@ node_modules/
dist/
dist_*/
# custom
# AI
.claude/
.serena/
#------# custom

68
.serena/project.yml Normal file
View 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"

View File

@@ -1,12 +1,86 @@
# Changelog
## 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)
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.
## 2024-11-06 - 2.2.2 - fix(core)
Fixed function call for fastPut in the test suite to ensure proper file upload handling.
- Updated dependencies in package.json to newer versions.
- Corrected the function call in test suite for file upload.
## 2024-10-26 - 2.2.1 - fix(core)
Fix import and typings for improved compatibility
- Corrected the type signature for `getS3Descriptor` to return `IS3Descriptor`.
- Fixed import structure and updated dependencies for consistent namespace usage across plugins.
## 2024-10-26 - 2.2.0 - feat(ci)
Migrate CI/CD workflow from GitLab CI to Gitea CI
- Added new Gitea CI workflows for both non-tag and tag-based pushes
- Removed existing GitLab CI configuration
## 2024-05-29 - 2.1.1 - Updates and minor changes
Updates and changes based on minor configuration improvements and organizational shifts.
- Updated description file.
@@ -15,22 +89,26 @@ Updates and changes based on minor configuration improvements and organizational
- Shifted to new organizational scheme.
## 2022-07-30 - 2.1.0 - Core improvements and fixes
Minor improvements and important core changes.
- Removed tslint from the core setup.
## 2022-07-30 - 2.0.2 - Bucket creation improvement
Enhanced file structure management.
- Improved bucket creation to store locally within the .nogit directory.
## 2022-04-14 - 2.0.0 to 2.0.1 - Structural updates and fixes
This release focused on core updates and structural changes.
- Reformatted the project structure.
- Core updates with minor fixes.
## 2021-12-20 - 1.0.10 - ESM Transition
Breaking changes and minor fixes, transitioning to ES Modules.
- BREAKING CHANGE: Transitioned core setup to ESM.

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smarts3",
"version": "2.2.0",
"version": "2.3.0",
"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.",
"main": "dist_ts/index.js",
@@ -9,17 +9,17 @@
"author": "Lossless GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --web)",
"test": "(tstest test/ --web --verbose --logfile --timeout 60)",
"build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.63",
"@git.zone/tsbundle": "^2.0.6",
"@git.zone/tsrun": "^1.2.49",
"@git.zone/tstest": "^1.0.72",
"@push.rocks/tapbundle": "^5.0.4",
"@types/node": "^18.6.2"
"@aws-sdk/client-s3": "^3.936.0",
"@git.zone/tsbuild": "^3.1.0",
"@git.zone/tsbundle": "^2.5.2",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.0.0",
"@types/node": "^22.9.0"
},
"browserslist": [
"last 1 chrome versions"
@@ -37,9 +37,11 @@
"readme.md"
],
"dependencies": {
"@push.rocks/smartbucket": "^2.0.2",
"@push.rocks/smartfile": "^10.0.4",
"@push.rocks/smartpath": "^5.0.5",
"@push.rocks/smartbucket": "^3.3.10",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartxml": "^1.0.6",
"@tsclass/tsclass": "^9.3.0",
"@types/s3rver": "^3.7.0",
"s3rver": "^3.7.1"
},
@@ -64,9 +66,13 @@
"homepage": "https://code.foss.global/push.rocks/smarts3#readme",
"repository": {
"type": "git",
"url": "git+https://code.foss.global/push.rocks/smarts3.git"
"url": "https://code.foss.global/push.rocks/smarts3.git"
},
"bugs": {
"url": "https://code.foss.global/push.rocks/smarts3/issues"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"pnpm": {
"overrides": {}
}
}

11831
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

505
readme.md
View File

@@ -1,196 +1,419 @@
```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.
**Mock S3 made simple** - A powerful Node.js TypeScript package for creating a local S3 endpoint that simulates AWS S3 operations using mapped local directories. Perfect for development and testing!
## 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
-**Native custom S3 server** - Built on Node.js http module with zero framework dependencies (default)
- 🔄 **Full AWS S3 API compatibility** - Drop-in replacement for AWS SDK v3 and other S3 clients
- 📂 **Local directory mapping** - Your buckets live right on your filesystem with Windows-compatible encoding
- 🧪 **Perfect for testing** - Reliable, repeatable tests without cloud dependencies
- 🎯 **TypeScript-first** - Built with TypeScript for excellent type safety and IDE support
- 🔧 **Zero configuration** - Works out of the box with sensible defaults
- 🧹 **Clean slate mode** - Start fresh on every test run
- 🔀 **Legacy compatibility** - Optional s3rver backend support for backward compatibility
```sh
npm install @push.rocks/smarts3 --save
## 📦 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
### 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. Heres 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:
Get up and running in seconds:
```typescript
import { Smarts3 } from '@push.rocks/smarts3';
async function startServer() {
const smarts3Instance = await Smarts3.createAndStart({
port: 3000,
cleanSlate: true,
});
// Start your local S3 server
const s3Server = await Smarts3.createAndStart({
port: 3000,
cleanSlate: true, // Start with empty buckets
});
console.log('S3 server is up and running at http://localhost:3000');
return smarts3Instance;
}
// Create a bucket
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:**
- **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.
## 📖 Detailed Usage Guide
### Creating and Managing Buckets
### 🏗️ Setting Up Your S3 Server
With your server running, create buckets for storing files. A bucket in S3 acts similarly to a root directory.
The `Smarts3` class provides a simple interface for managing your local S3 server:
```typescript
async function createBucket(smarts3Instance: Smarts3, bucketName: string) {
const bucket = await smarts3Instance.createBucket(bucketName);
console.log(`Bucket created: ${bucket.name}`);
}
import { Smarts3 } from '@push.rocks/smarts3';
startServer()
.then((smarts3Instance) => createBucket(smarts3Instance, 'my-awesome-bucket'))
.catch(console.error);
// Configuration options
const config = {
port: 3000, // Port to run the server on (default: 3000)
cleanSlate: true, // Clear all data on start (default: false)
};
// Create and start in one go
const s3Server = await Smarts3.createAndStart(config);
// Or create and start separately
const s3Server = new Smarts3(config);
await s3Server.start();
```
### Uploading and Managing Files
### 🪣 Working with Buckets
Uploading files to a bucket uses the `SmartBucket` module, part of the `@push.rocks/smartbucket` ecosystem:
Creating and managing buckets is straightforward:
```typescript
// Create a new bucket
const bucket = await s3Server.createBucket('my-bucket');
// The bucket is now ready to use!
console.log(`Created bucket: ${bucket.name}`);
```
### 📤 Uploading Files
Use the powerful `SmartBucket` integration for file operations:
```typescript
import { SmartBucket } from '@push.rocks/smartbucket';
async function uploadFile(smarts3Instance: Smarts3, bucketName: string, filePath: string, fileContent: string) {
const s3Descriptor = await smarts3Instance.getS3Descriptor();
const smartbucketInstance = new SmartBucket(s3Descriptor);
const bucket = await smartbucketInstance.getBucket(bucketName);
// Get connection configuration
const s3Config = await s3Server.getS3Descriptor();
await bucket.getBaseDirectory().fastStore(filePath, fileContent);
console.log(`File "${filePath}" uploaded successfully to bucket "${bucketName}".`);
}
// Create a SmartBucket instance
const smartbucket = new SmartBucket(s3Config);
startServer()
.then(async (smarts3Instance) => {
await createBucket(smarts3Instance, 'my-awesome-bucket');
await uploadFile(smarts3Instance, 'my-awesome-bucket', 'hello.txt', 'Hello, world!');
})
.catch(console.error);
// Get your bucket
const bucket = await smartbucket.getBucket('my-bucket');
// Upload a file
const baseDir = await bucket.getBaseDirectory();
await baseDir.fastStore('path/to/file.txt', 'Hello, S3! 🎉');
// Upload with more control
await baseDir.fastPut({
path: 'documents/important.pdf',
contents: Buffer.from(yourPdfData),
});
```
### Listing Files in a Bucket
### 📥 Downloading Files
Listing files within a bucket allows you to manage its contents conveniently:
Retrieve your files easily:
```typescript
async function listFiles(smarts3Instance: Smarts3, bucketName: string) {
const s3Descriptor = await smarts3Instance.getS3Descriptor();
const smartbucketInstance = new SmartBucket(s3Descriptor);
const bucket = await smartbucketInstance.getBucket(bucketName);
// Get file contents as string
const content = await baseDir.fastGet('path/to/file.txt');
console.log(content); // "Hello, S3! 🎉"
const baseDirectory = await bucket.getBaseDirectory();
const files = await baseDirectory.listFiles();
console.log(`Files in bucket "${bucketName}":`, files);
}
startServer()
.then(async (smarts3Instance) => {
await createBucket(smarts3Instance, 'my-awesome-bucket');
await listFiles(smarts3Instance, 'my-awesome-bucket');
})
.catch(console.error);
// Get file as Buffer
const buffer = await baseDir.fastGetBuffer('documents/important.pdf');
```
### Deleting a File
### 📋 Listing Files
Managing storage efficiently involves deleting files when necessary:
Browse your bucket contents:
```typescript
async function deleteFile(smarts3Instance: Smarts3, bucketName: string, filePath: string) {
const s3Descriptor = await smarts3Instance.getS3Descriptor();
const smartbucketInstance = new SmartBucket(s3Descriptor);
const bucket = await smartbucketInstance.getBucket(bucketName);
// List all files in the bucket
const files = await baseDir.listFiles();
await bucket.getBaseDirectory().fastDelete(filePath);
console.log(`File "${filePath}" deleted from bucket "${bucketName}".`);
}
files.forEach((file) => {
console.log(`📄 ${file.name} (${file.size} bytes)`);
});
startServer()
.then(async (smarts3Instance) => {
await createBucket(smarts3Instance, 'my-awesome-bucket');
await deleteFile(smarts3Instance, 'my-awesome-bucket', 'hello.txt');
})
.catch(console.error);
// List files with a specific prefix
const docs = await baseDir.listFiles('documents/');
```
### Scenario Integrations
### 🗑️ Deleting Files
#### 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:
Clean up when needed:
```typescript
async function stopServer(smarts3Instance: Smarts3) {
await smarts3Instance.stop();
console.log('S3 server has been stopped.');
// Delete a single file
await baseDir.fastDelete('old-file.txt');
// Delete multiple files
const filesToDelete = ['temp1.txt', 'temp2.txt', 'temp3.txt'];
for (const file of filesToDelete) {
await baseDir.fastDelete(file);
}
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`.
## 🧪 Testing Integration
### Using with Jest
```typescript
import { Smarts3 } from '@push.rocks/smarts3';
describe('S3 Operations', () => {
let s3Server: Smarts3;
beforeAll(async () => {
s3Server = await Smarts3.createAndStart({
port: 9999,
cleanSlate: true,
});
});
afterAll(async () => {
await s3Server.stop();
});
test('should upload and retrieve a file', async () => {
const bucket = await s3Server.createBucket('test-bucket');
// Your test logic here
});
});
```
### Using with Mocha
```typescript
import { Smarts3 } from '@push.rocks/smarts3';
import { expect } from 'chai';
describe('S3 Operations', () => {
let s3Server: Smarts3;
before(async () => {
s3Server = await Smarts3.createAndStart({
port: 9999,
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
});
});
```
## 🔌 AWS SDK Integration
Use `smarts3` with the official AWS SDK:
```typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { Smarts3 } from '@push.rocks/smarts3';
// Start local S3
const s3Server = await Smarts3.createAndStart({ port: 3000 });
const config = await s3Server.getS3Descriptor();
// Configure AWS SDK
const s3Client = new S3Client({
endpoint: `http://${config.endpoint}:${config.port}`,
region: 'us-east-1',
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.accessSecret,
},
forcePathStyle: true,
});
// Use AWS SDK as normal
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'test-file.txt',
Body: 'Hello from AWS SDK!',
});
await s3Client.send(command);
```
## 🎯 Real-World Examples
### CI/CD Pipeline Testing
```typescript
// ci-test.ts
import { Smarts3 } from '@push.rocks/smarts3';
export async function setupTestEnvironment() {
// Start S3 server for CI tests
const s3 = await Smarts3.createAndStart({
port: process.env.S3_PORT || 3000,
cleanSlate: true,
});
// Create test buckets
await s3.createBucket('uploads');
await s3.createBucket('processed');
await s3.createBucket('archive');
return s3;
}
```
### Microservice Development
```typescript
// dev-server.ts
import { Smarts3 } from '@push.rocks/smarts3';
import express from 'express';
async function startDevelopmentServer() {
// Start local S3
const s3 = await Smarts3.createAndStart({ port: 3000 });
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';
async function testDataMigration() {
const s3 = await Smarts3.createAndStart({ cleanSlate: true });
// Create source and destination buckets
const sourceBucket = await s3.createBucket('legacy-data');
const destBucket = 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();
// Add test files
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!`);
}
```
## 🛠️ Advanced Configuration
### Custom S3 Descriptor Options
When integrating with different S3 clients, you can customize the connection details:
```typescript
const customDescriptor = await s3Server.getS3Descriptor({
endpoint: 'localhost', // Custom endpoint
port: 3001, // Different port
useSsl: false, // SSL configuration
// Add any additional options your S3 client needs
});
```
### Environment-Based Configuration
```typescript
const config = {
port: parseInt(process.env.S3_PORT || '3000'),
cleanSlate: process.env.NODE_ENV === 'test',
};
const s3Server = await Smarts3.createAndStart(config);
```
## 🤝 Use Cases
- **🧪 Unit & Integration Testing** - Test S3 operations without AWS credentials or internet
- **🏗️ Local Development** - Develop cloud features offline with full S3 compatibility
- **📚 Teaching & Demos** - Perfect for workshops and tutorials without AWS setup
- **🔄 CI/CD Pipelines** - Reliable S3 operations in containerized test environments
- **🎭 Mocking & Stubbing** - Replace real S3 calls in test suites
- **📊 Data Migration Testing** - Safely test data migrations locally before production
## 🔧 API Reference
### Smarts3 Class
#### Constructor Options
```typescript
interface ISmarts3ContructorOptions {
port?: number; // Server port (default: 3000)
cleanSlate?: boolean; // Clear storage on start (default: false)
}
```
#### Methods
- `static createAndStart(options)` - Create and start server in one call
- `start()` - Start the S3 server
- `stop()` - Stop the S3 server
- `createBucket(name)` - Create a new bucket
- `getS3Descriptor(options?)` - Get S3 connection configuration
## 🐛 Debugging Tips
1. **Enable verbose logging** - The server logs all operations by default
2. **Check the buckets directory** - Find your data in `.nogit/bucketsDir/`
3. **Use the correct endpoint** - Remember to use `127.0.0.1` or `localhost`
4. **Force path style** - Always use path-style URLs with local S3
## 📈 Performance
`@push.rocks/smarts3` is optimized for development and testing:
-**Instant operations** - No network latency
- 💾 **Low memory footprint** - Efficient file system usage
- 🔄 **Fast cleanup** - Clean slate mode for quick test resets
- 🚀 **Parallel operations** - Handle multiple requests simultaneously
## 🔗 Related Packages
- [`@push.rocks/smartbucket`](https://www.npmjs.com/package/@push.rocks/smartbucket) - Powerful S3 abstraction layer
- [`@push.rocks/smartfile`](https://www.npmjs.com/package/@push.rocks/smartfile) - Advanced file system operations
- [`@tsclass/tsclass`](https://www.npmjs.com/package/@tsclass/tsclass) - TypeScript class helpers
- [`s3rver`](https://www.npmjs.com/package/s3rver) - Optional legacy S3 server implementation (used when `useCustomServer: false`)
## License and Legal Information
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.

104
test/test.aws-sdk.node.ts Normal file
View File

@@ -0,0 +1,104 @@
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({
port: 3337,
cleanSlate: true,
silent: 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();

View File

@@ -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 smarts3 from '../ts/index.js';
@@ -7,7 +7,7 @@ let testSmarts3Instance: smarts3.Smarts3;
tap.test('should create a smarts3 instance and run it', async (toolsArg) => {
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
port: 3000,
port: 3333,
cleanSlate: true,
});
console.log(`Let the instance run for 2 seconds`);
@@ -20,7 +20,10 @@ tap.test('should be able to access buckets', async () => {
);
const bucket = await smartbucketInstance.createBucket('testbucket');
const baseDirectory = await bucket.getBaseDirectory();
await baseDirectory.fastStore('subdir/hello.txt', 'hi there!');
await baseDirectory.fastPut({
path: 'subdir/hello.txt',
contents: 'hi there!',
});
});
tap.test('should stop the instance', async () => {

View File

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

114
ts/classes/context.ts Normal file
View File

@@ -0,0 +1,114 @@
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 { 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;
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
) {
this.req = req;
this.res = res;
this.store = store;
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;
}
}

View File

@@ -0,0 +1,495 @@
import * as plugins from '../plugins.js';
import { S3Error } from './s3-error.js';
import type { 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
*/
export class FilesystemStore {
constructor(private rootDir: string) {}
/**
* Initialize store (ensure root directory exists)
*/
public async initialize(): Promise<void> {
await plugins.fs.promises.mkdir(this.rootDir, { recursive: true });
}
/**
* Reset store (delete all buckets)
*/
public async reset(): Promise<void> {
await plugins.smartfile.fs.ensureEmptyDir(this.rootDir);
}
// ============================
// BUCKET OPERATIONS
// ============================
/**
* List all buckets
*/
public async listBuckets(): Promise<IS3Bucket[]> {
const dirs = await plugins.smartfile.fs.listFolders(this.rootDir);
const buckets: IS3Bucket[] = [];
for (const dir of dirs) {
const bucketPath = plugins.path.join(this.rootDir, dir);
const stats = await plugins.smartfile.fs.stat(bucketPath);
buckets.push({
name: dir,
creationDate: 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.smartfile.fs.isDirectory(bucketPath);
}
/**
* Create bucket
*/
public async createBucket(bucket: string): Promise<void> {
const bucketPath = this.getBucketPath(bucket);
await plugins.fs.promises.mkdir(bucketPath, { recursive: true });
}
/**
* 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.smartfile.fs.listFileTree(bucketPath, '**/*');
if (files.length > 0) {
throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty');
}
await plugins.smartfile.fs.remove(bucketPath);
}
// ============================
// 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
const objectPattern = '**/*._S3_object';
const objectFiles = await plugins.smartfile.fs.listFileTree(bucketPath, objectPattern);
// Convert file paths to keys
let keys = objectFiles.map((filePath) => {
const relativePath = plugins.path.relative(bucketPath, filePath);
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.smartfile.fs.stat(objectPath),
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.smartfile.fs.fileExists(objectPath);
}
/**
* 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
await plugins.fs.promises.mkdir(plugins.path.dirname(objectPath), { recursive: true });
// Write with MD5 calculation
const result = await this.writeStreamWithMD5(stream, objectPath);
// Save metadata
const metadataPath = `${objectPath}.metadata.json`;
await plugins.fs.promises.writeFile(metadataPath, 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);
// Create read stream with optional range (using native fs for range support)
const stream = range
? plugins.fs.createReadStream(objectPath, { start: range.start, end: range.end })
: plugins.fs.createReadStream(objectPath);
return {
...info,
content: stream,
};
}
/**
* 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.smartfile.fs.remove(objectPath).catch(() => {}),
plugins.smartfile.fs.remove(metadataPath).catch(() => {}),
plugins.smartfile.fs.remove(md5Path).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
await plugins.fs.promises.mkdir(plugins.path.dirname(destObjectPath), { recursive: true });
// Copy object file
await plugins.smartfile.fs.copy(srcObjectPath, destObjectPath);
// Handle metadata
if (metadataDirective === 'COPY') {
// Copy metadata
const srcMetadataPath = `${srcObjectPath}.metadata.json`;
const destMetadataPath = `${destObjectPath}.metadata.json`;
await plugins.smartfile.fs.copy(srcMetadataPath, destMetadataPath).catch(() => {});
} else if (newMetadata) {
// Replace with new metadata
const destMetadataPath = `${destObjectPath}.metadata.json`;
await plugins.fs.promises.writeFile(destMetadataPath, JSON.stringify(newMetadata, null, 2));
}
// Copy MD5
const srcMD5Path = `${srcObjectPath}.md5`;
const destMD5Path = `${destObjectPath}.md5`;
await plugins.smartfile.fs.copy(srcMD5Path, destMD5Path).catch(() => {});
// Get result info
const stats = await plugins.smartfile.fs.stat(destObjectPath);
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((resolve, reject) => {
const output = plugins.fs.createWriteStream(destPath);
input.on('data', (chunk: Buffer) => {
hash.update(chunk);
totalSize += chunk.length;
});
input.on('error', reject);
output.on('error', reject);
input.pipe(output).on('finish', async () => {
const md5 = hash.digest('hex');
// Save MD5 to separate file
const md5Path = `${destPath}.md5`;
await plugins.fs.promises.writeFile(md5Path, md5);
resolve({ size: totalSize, md5 });
});
});
}
/**
* 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.smartfile.fs.toStringSync(md5Path);
return md5.trim();
} catch (err) {
// Calculate MD5 if not cached
return new Promise((resolve, reject) => {
const hash = plugins.crypto.createHash('md5');
const stream = plugins.fs.createReadStream(objectPath);
stream.on('data', (chunk: Buffer) => hash.update(chunk));
stream.on('end', async () => {
const md5 = hash.digest('hex');
// Cache it
await plugins.fs.promises.writeFile(md5Path, md5);
resolve(md5);
});
stream.on('error', reject);
});
}
}
/**
* Read metadata from JSON file
*/
private async readMetadata(metadataPath: string): Promise<Record<string, string>> {
try {
const content = await plugins.smartfile.fs.toStringSync(metadataPath);
return JSON.parse(content);
} catch (err) {
return {};
}
}
}

View 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();
}
}

129
ts/classes/router.ts Normal file
View 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
View 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 }
);
}
}

View File

@@ -0,0 +1,239 @@
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 { ServiceController } from '../controllers/service.controller.js';
import { BucketController } from '../controllers/bucket.controller.js';
import { ObjectController } from '../controllers/object.controller.js';
export interface ISmarts3ServerOptions {
port?: number;
address?: string;
directory?: string;
cleanSlate?: boolean;
silent?: boolean;
}
/**
* 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;
private store: FilesystemStore;
private options: Required<ISmarts3ServerOptions>;
constructor(options: ISmarts3ServerOptions = {}) {
this.options = {
port: 3000,
address: '0.0.0.0',
directory: plugins.path.join(process.cwd(), '.nogit/bucketsDir'),
cleanSlate: false,
silent: false,
...options,
};
this.store = new FilesystemStore(this.options.directory);
this.router = new S3Router();
this.middlewares = new MiddlewareStack();
this.setupMiddlewares();
this.setupRoutes();
}
/**
* Setup middleware stack
*/
private setupMiddlewares(): void {
// Logger middleware
if (!this.options.silent) {
this.middlewares.use(async (req, res, ctx, next) => {
const start = Date.now();
console.log(`${req.method} ${req.url}`);
console.log(` Headers:`, JSON.stringify(req.headers, null, 2).slice(0, 200));
await next();
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} - ${res.statusCode} (${duration}ms)`);
});
}
// TODO: Add authentication middleware
// TODO: Add CORS middleware
}
/**
* 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.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);
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);
if (!this.options.silent) {
console.error(`[S3Error] ${s3Error.code}: ${s3Error.message}`);
if (s3Error.status >= 500) {
console.error(err.stack || err);
}
}
// 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();
// 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) => {
console.error('Fatal error in request handler:', err);
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 {
if (!this.options.silent) {
console.log(`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 {
if (!this.options.silent) {
console.log('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,
};
}
}

View 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',
})),
},
});
}
}
}

View File

@@ -0,0 +1,204 @@
import * as plugins from '../plugins.js';
import type { S3Context } from '../classes/context.js';
/**
* Object-level operations
*/
export class ObjectController {
/**
* PUT /:bucket/:key* - Upload object or copy object
*/
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 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
*/
public static async deleteObject(
req: plugins.http.IncomingMessage,
res: plugins.http.ServerResponse,
ctx: S3Context,
params: Record<string, string>
): Promise<void> {
const { bucket, key } = 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}"`,
},
});
}
}

View 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(),
})),
},
},
});
}
}

View File

@@ -1,14 +1,18 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { Smarts3Server } from './classes/smarts3-server.js';
export interface ISmarts3ContructorOptions {
port?: number;
cleanSlate?: boolean;
useCustomServer?: boolean; // Feature flag for custom server
}
export class Smarts3 {
// STATIC
public static async createAndStart(optionsArg: ConstructorParameters<typeof Smarts3>[0]) {
public static async createAndStart(
optionsArg: ConstructorParameters<typeof Smarts3>[0],
) {
const smartS3Instance = new Smarts3(optionsArg);
await smartS3Instance.start();
return smartS3Instance;
@@ -16,49 +20,85 @@ export class Smarts3 {
// INSTANCE
public options: ISmarts3ContructorOptions;
public s3Instance: plugins.s3rver;
public s3Instance: plugins.s3rver | Smarts3Server;
constructor(optionsArg: ISmarts3ContructorOptions) {
this.options = optionsArg;
this.options = {
...this.options,
useCustomServer: true, // Default to custom server
...optionsArg,
};
}
public async start() {
if (this.options.cleanSlate) {
await plugins.smartfile.fs.ensureEmptyDir(paths.bucketsDir);
if (this.options.useCustomServer) {
// Use new custom server
this.s3Instance = new Smarts3Server({
port: this.options.port || 3000,
address: '0.0.0.0',
directory: paths.bucketsDir,
cleanSlate: this.options.cleanSlate || false,
silent: false,
});
await this.s3Instance.start();
console.log('s3 server is running (custom implementation)');
} else {
await plugins.smartfile.fs.ensureDir(paths.bucketsDir);
// Use legacy s3rver
if (this.options.cleanSlate) {
await plugins.smartfile.fs.ensureEmptyDir(paths.bucketsDir);
} else {
await plugins.smartfile.fs.ensureDir(paths.bucketsDir);
}
this.s3Instance = new plugins.s3rver({
port: this.options.port || 3000,
address: '0.0.0.0',
silent: false,
directory: paths.bucketsDir,
});
await (this.s3Instance as plugins.s3rver).run();
console.log('s3 server is running (legacy s3rver)');
}
this.s3Instance = new plugins.s3rver({
port: this.options.port || 3000,
address: '0.0.0.0',
silent: false,
directory: paths.bucketsDir,
});
await this.s3Instance.run();
console.log('s3 server is running');
}
public async getS3Descriptor(): Promise<plugins.smartbucket.ISmartBucketConfig> {
public async getS3Descriptor(
optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
): Promise<plugins.tsclass.storage.IS3Descriptor> {
if (this.options.useCustomServer && this.s3Instance instanceof Smarts3Server) {
const descriptor = this.s3Instance.getS3Descriptor();
return {
...descriptor,
...(optionsArg ? optionsArg : {}),
};
}
// Legacy s3rver descriptor
return {
accessKey: 'S3RVER',
accessSecret: 'S3RVER',
endpoint: 'localhost',
port: this.options.port,
useSsl: false,
...{
accessKey: 'S3RVER',
accessSecret: 'S3RVER',
endpoint: '127.0.0.1',
port: this.options.port || 3000,
useSsl: false,
},
...(optionsArg ? optionsArg : {}),
};
}
public async createBucket(bucketNameArg: string) {
const smartbucketInstance = new plugins.smartbucket.SmartBucket(await this.getS3Descriptor());
const smartbucketInstance = new plugins.smartbucket.SmartBucket(
await this.getS3Descriptor(),
);
const bucket = await smartbucketInstance.createBucket(bucketNameArg);
return bucket;
}
public async stop() {
await this.s3Instance.close();
if (this.s3Instance instanceof Smarts3Server) {
await this.s3Instance.stop();
} else {
await (this.s3Instance as plugins.s3rver).close();
}
}
}
// Export the custom server class for direct use
export { Smarts3Server } from './classes/smarts3-server.js';

View File

@@ -1,14 +1,24 @@
// node native
import * as path from 'path';
import * as http from 'http';
import * as crypto from 'crypto';
import * as url from 'url';
import * as fs from 'fs';
export { path };
export { path, http, crypto, url, fs };
// pushrocks scope
// @push.rocks scope
import * as smartbucket from '@push.rocks/smartbucket';
import * as smartfile from '@push.rocks/smartfile';
import * as smartpath from '@push.rocks/smartpath';
import { SmartXml } from '@push.rocks/smartxml';
export { smartbucket, smartfile, smartpath };
export { smartbucket, smartfile, smartpath, SmartXml };
// @tsclass scope
import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// thirdparty scope
import s3rver from 's3rver';

39
ts/utils/xml.utils.ts Normal file
View 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 });
}

View File

@@ -1,14 +1,12 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
},
"exclude": [
"dist_*/**/*.d.ts"
]
"exclude": ["dist_*/**/*.d.ts"]
}