first commit

This commit is contained in:
William Valentin
2025-08-15 23:45:13 -07:00
commit c167c8623c
29 changed files with 1971 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import express from 'express';
import { setRoutes } from './routes/index';
import { errorHandler } from './middlewares/errorHandler';
import requestLogger from './middlewares/requestLogger';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(requestLogger);
app.use(express.json());
setRoutes(app);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
@@ -0,0 +1,23 @@
import { Request, Response } from 'express';
import StreamService from '../services/streamService';
export default class StreamController {
constructor(private streamService: StreamService) { }
// GET /api/streams/:key
async streamAudio(req: Request, res: Response) {
const { fileName: key } = req.params as { fileName: string };
const range = req.headers.range as string | undefined;
if (!key) return res.status(400).send('Missing file key');
try {
const result = await this.streamService.streamFromS3({ key, range });
res.writeHead(result.status, result.headers);
result.body.pipe(res);
} catch (err: any) {
const message = err?.message || 'Error streaming audio';
const status = err?.statusCode || 500;
res.status(status).send(message);
}
}
}
@@ -0,0 +1,10 @@
import { ErrorRequestHandler } from 'express';
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const message = err?.message || 'An error occurred';
const status = (err as any)?.statusCode || 500;
if (process.env.NODE_ENV !== 'test') {
console.error(err?.stack || message);
}
res.status(status).json({ message, error: message });
};
@@ -0,0 +1,8 @@
import { Request, Response, NextFunction } from 'express';
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.url}`);
next();
};
export default requestLogger;
+18
View File
@@ -0,0 +1,18 @@
import { Application, Router, Request, Response } from 'express';
import StreamController from '../controllers/streamController';
import StreamService from '../services/streamService';
const router = Router();
const streamService = new StreamService();
const streamController = new StreamController(streamService);
export function setRoutes(app: Application) {
// Health endpoints
app.get('/', (_req: Request, res: Response) => res.status(200).send('OK'));
app.get('/healthz', (_req: Request, res: Response) => res.status(200).json({ status: 'ok' }));
app.use('/api/streams', router);
router.get('/:fileName', streamController.streamAudio.bind(streamController));
}
export default router;
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import StreamController from '../controllers/streamController';
import StreamService from '../services/streamService';
const router = Router();
const streamService = new StreamService();
const streamController = new StreamController(streamService);
router.get('/:fileName', streamController.streamAudio.bind(streamController));
export default router;
+49
View File
@@ -0,0 +1,49 @@
import { S3 } from 'aws-sdk';
import { Readable } from 'stream';
export interface GetObjectStreamResult {
stream: Readable;
contentLength: number; // length of the returned body
totalLength: number; // full object size
contentType?: string;
contentRange?: string;
}
export default class S3Client {
private s3: S3;
constructor() {
this.s3 = new S3({
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
endpoint: process.env.S3_ENDPOINT || undefined,
});
}
async headObject(bucket: string, key: string) {
return this.s3.headObject({ Bucket: bucket, Key: key }).promise();
}
async getObjectStream(bucket: string, key: string, range?: { start: number; end: number }): Promise<GetObjectStreamResult> {
const head = await this.headObject(bucket, key);
const totalLength = head.ContentLength || 0;
const contentType = head.ContentType;
const params: S3.GetObjectRequest = { Bucket: bucket, Key: key };
let contentRange: string | undefined;
if (range && totalLength > 0) {
params.Range = `bytes=${range.start}-${range.end}`;
contentRange = `bytes ${range.start}-${range.end}/${totalLength}`;
}
const req = this.s3.getObject(params);
const stream = req.createReadStream();
// When a range is requested, S3 returns partial content length (end-start+1)
const contentLength = range && totalLength > 0 ? range.end - range.start + 1 : totalLength;
return { stream, contentLength, totalLength, contentType, contentRange };
}
}
@@ -0,0 +1,57 @@
import { Readable } from 'stream';
import S3Client, { GetObjectStreamResult } from './s3Client';
import { rangeParser } from '../utils/rangeParser';
export interface StreamResult {
status: number;
headers: Record<string, string>;
body: Readable;
}
export default class StreamService {
private s3: S3Client;
private bucket: string;
private prefix: string;
constructor(bucket = process.env.S3_BUCKET || '', prefix = process.env.S3_PREFIX || '') {
this.s3 = new S3Client();
this.bucket = bucket;
this.prefix = (prefix || '').replace(/^\/+|\/+$/g, ''); // trim leading/trailing '/'
}
async streamFromS3({ key, range }: { key: string; range?: string }): Promise<StreamResult> {
if (!this.bucket) throw new Error('S3_BUCKET not configured');
const actualKey = this.buildKey(key);
// Determine byte range
let parsedRange: { start: number; end: number } | undefined;
// We need object size to parse range properly, so head first
const head = await this.s3.headObject(this.bucket, actualKey);
const total = head.ContentLength || 0;
if (range) {
const r = rangeParser(range, total);
if (r) parsedRange = r;
}
const { stream, contentLength, totalLength, contentType, contentRange }: GetObjectStreamResult =
await this.s3.getObjectStream(this.bucket, actualKey, parsedRange);
const isPartial = Boolean(parsedRange);
const status = isPartial ? 206 : 200;
const headers: Record<string, string> = {
'Content-Type': contentType || 'audio/mp4',
'Accept-Ranges': 'bytes',
'Content-Length': String(contentLength),
};
if (isPartial && contentRange) headers['Content-Range'] = contentRange;
return { status, headers, body: stream };
}
private buildKey(key: string): string {
const cleanKey = (key || '').replace(/^\/+/, '');
if (!this.prefix) return cleanKey;
return `${this.prefix}/${cleanKey}`;
}
}
+17
View File
@@ -0,0 +1,17 @@
export interface AudioFile {
key: string;
bucket: string;
contentType: string;
size: number;
}
export interface StreamRequest {
range: string;
audioFile: AudioFile;
}
export interface StreamResponse {
status: number;
headers: Record<string, string>;
body: Buffer;
}
+15
View File
@@ -0,0 +1,15 @@
export function rangeParser(range: string, size: number): { start: number; end: number } | null {
if (!range) {
return null;
}
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
if (isNaN(start) || isNaN(end) || start > end || start >= size) {
return null;
}
return { start, end };
}