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.

This commit is contained in:
2025-11-21 14:32:19 +00:00
parent 3ab667049a
commit 18a2eb7e3f
18 changed files with 1949 additions and 632 deletions

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,
};
}
}