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; 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 { 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 { 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 { // 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((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 { if (!this.httpServer) { return; } await new Promise((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, }; } }