desktop-audio-proxy
Version:
A comprehensive audio streaming solution for Tauri and Electron apps that bypasses CORS and WebKit codec issues
406 lines (367 loc) • 12.7 kB
text/typescript
import express, { Request, Response } from 'express';
import cors from 'cors';
import axios, { AxiosResponse, AxiosError } from 'axios';
import { Readable } from 'stream';
import { createServer } from 'net';
import { Server as HttpServer } from 'http';
import { ProxyConfig } from './types';
// Utility function to check if a port is available
async function isPortAvailable(
port: number,
host: string = 'localhost'
): Promise<boolean> {
return new Promise(resolve => {
const server = createServer();
server.listen(port, host, () => {
server.close(() => {
resolve(true);
});
});
server.on('error', () => {
resolve(false);
});
});
}
// Find the next available port starting from the given port
async function findAvailablePort(
startPort: number,
host: string = 'localhost',
maxAttempts: number = 10
): Promise<number> {
for (let i = 0; i < maxAttempts; i++) {
const port = startPort + i;
const available = await isPortAvailable(port, host);
if (available) {
return port;
}
}
throw new Error(
`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`
);
}
export class AudioProxyServer {
private app: express.Application;
private server: HttpServer | null = null;
private config: Required<ProxyConfig>;
private actualPort: number = 0;
constructor(config: ProxyConfig = {}) {
this.config = {
port: config.port || 3002,
host: config.host || 'localhost',
corsOrigins: config.corsOrigins || '*',
timeout: config.timeout || 60000,
maxRedirects: config.maxRedirects || 10,
userAgent: config.userAgent || 'AudioProxy/1.0',
enableLogging: config.enableLogging ?? true,
enableTranscoding: config.enableTranscoding ?? false,
cacheEnabled: config.cacheEnabled ?? true,
cacheTTL: config.cacheTTL || 3600,
};
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
// CORS middleware
this.app.use(
cors({
origin: this.config.corsOrigins,
credentials: true,
exposedHeaders: ['Content-Length', 'Content-Range', 'Accept-Ranges'],
methods: ['GET', 'OPTIONS', 'HEAD'],
allowedHeaders: ['Content-Type', 'Range', 'Accept-Encoding'],
})
);
// Logging middleware
if (this.config.enableLogging) {
this.app.use((req: Request, res: Response, next) => {
console.log(`[AudioProxy] ${req.method} ${req.path}`);
next();
});
}
}
private setupRoutes(): void {
// Handle CORS preflight for all routes
this.app.options('*', (req: Request, res: Response) => {
res.set({
'Access-Control-Allow-Origin': this.config.corsOrigins,
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers':
'Content-Type, Range, Accept-Encoding, User-Agent',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400', // 24 hours
});
res.status(204).end();
});
// Health check endpoint
this.app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'ok',
version: '1.1.1',
uptime: process.uptime(),
config: {
port: this.actualPort || this.config.port,
configuredPort: this.config.port,
enableTranscoding: this.config.enableTranscoding,
cacheEnabled: this.config.cacheEnabled,
},
});
});
// Info endpoint
this.app.get('/info', async (req: Request, res: Response) => {
const url = req.query.url as string;
if (!url) {
return res.status(400).json({ error: 'URL parameter required' });
}
try {
// Get stream info without downloading
const response = await axios({
method: 'HEAD',
url: url,
headers: {
'User-Agent': this.config.userAgent,
Accept: 'audio/*,*/*;q=0.1',
},
timeout: this.config.timeout,
maxRedirects: this.config.maxRedirects,
validateStatus: status => status < 400,
});
res.json({
url,
status: response.status,
headers: response.headers,
contentType: response.headers['content-type'],
contentLength: response.headers['content-length'],
acceptRanges: response.headers['accept-ranges'],
lastModified: response.headers['last-modified'],
});
} catch (error: unknown) {
console.error('[AudioProxy] Info error:', error);
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
res.status(axiosError.response.status).json({
error: `Upstream error: ${axiosError.response.status} ${axiosError.response.statusText}`,
url: url,
});
}
} else {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({
error: 'Failed to get stream info',
message: errorMessage,
url: url,
});
}
}
});
// Proxy endpoint
this.app.get('/proxy', async (req: Request, res: Response) => {
const url = req.query.url as string;
if (!url) {
return res.status(400).json({ error: 'URL parameter required' });
}
try {
// Set CORS headers immediately
res.set({
'Access-Control-Allow-Origin': this.config.corsOrigins,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Expose-Headers':
'Content-Length, Content-Range, Accept-Ranges',
'Access-Control-Allow-Methods': 'GET, OPTIONS, HEAD',
'Access-Control-Allow-Headers':
'Content-Type, Range, Accept-Encoding',
});
// Prepare request headers
const requestHeaders: Record<string, string> = {
'User-Agent': this.config.userAgent,
Accept: req.headers.accept || 'audio/*,*/*;q=0.1',
'Accept-Language': req.headers['accept-language'] || 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
};
// Handle range requests for seeking support
if (req.headers.range) {
requestHeaders['Range'] = req.headers.range;
}
// Handle encoding
if (req.headers['accept-encoding']) {
requestHeaders['Accept-Encoding'] = req.headers['accept-encoding'];
}
// Use axios for better stream handling
const response: AxiosResponse = await axios({
method: 'GET',
url: url,
headers: requestHeaders,
responseType: 'stream',
timeout: this.config.timeout,
maxRedirects: this.config.maxRedirects,
validateStatus: status => status < 400, // Accept redirects and success codes
});
// Set response status
res.status(response.status);
// Copy relevant headers from the original response
const headersToProxy = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'cache-control',
'expires',
'last-modified',
'etag',
];
headersToProxy.forEach(header => {
const value = response.headers[header];
if (value) {
res.set(header, value);
}
});
// Handle errors during streaming
const stream = response.data as Readable;
stream.on('error', error => {
console.error('[AudioProxy] Stream error:', error);
if (!res.headersSent) {
res.status(500).json({
error: 'Stream error',
message: error.message,
});
} else {
res.end();
}
});
res.on('close', () => {
// Clean up stream if client disconnects
if (stream && !stream.destroyed) {
stream.destroy();
}
});
res.on('error', error => {
console.error('[AudioProxy] Response error:', error);
if (stream && !stream.destroyed) {
stream.destroy();
}
});
// Pipe the stream to response
stream.pipe(res);
} catch (error: unknown) {
console.error('[AudioProxy] Proxy error:', error);
if (!res.headersSent) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
// HTTP error from upstream
res.status(axiosError.response.status).json({
error: `Upstream error: ${axiosError.response.status} ${axiosError.response.statusText}`,
url: url,
});
}
} else if (error && typeof error === 'object' && 'code' in error) {
const nodeError = error as { code: string; message?: string };
if (nodeError.code === 'ENOTFOUND') {
// DNS resolution failed
res.status(404).json({
error: 'Audio source not found',
message: 'Unable to resolve hostname',
url: url,
});
} else if (nodeError.code === 'ECONNREFUSED') {
// Connection refused
res.status(503).json({
error: 'Audio source unavailable',
message: 'Connection refused',
url: url,
});
} else if (nodeError.code === 'ETIMEDOUT') {
// Request timeout
res.status(408).json({
error: 'Request timeout',
message: 'Audio source did not respond in time',
url: url,
});
} else {
// Generic error with code
const errorMessage = nodeError.message || 'Unknown error';
res.status(500).json({
error: 'Proxy request failed',
message: errorMessage,
url: url,
});
}
} else {
// Generic error
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({
error: 'Proxy request failed',
message: errorMessage,
url: url,
});
}
}
}
});
}
public async start(): Promise<void> {
try {
// Find an available port starting from the configured port
this.actualPort = await findAvailablePort(
this.config.port,
this.config.host
);
return new Promise((resolve, reject) => {
this.server = this.app.listen(this.actualPort, this.config.host, () => {
if (this.actualPort !== this.config.port) {
console.log(
`⚠️ Port ${this.config.port} was occupied, using port ${this.actualPort} instead`
);
}
console.log(
`Desktop Audio Proxy running on http://${this.config.host}:${this.actualPort}`
);
console.log(
`Use http://${this.config.host}:${this.actualPort}/proxy?url=YOUR_AUDIO_URL`
);
resolve();
});
this.server.on('error', (error: Error) => {
reject(error);
});
});
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to start proxy server: ${errorMessage}`);
}
}
public async stop(): Promise<void> {
return new Promise(resolve => {
if (this.server) {
this.server.close(() => {
console.log('Desktop Audio Proxy stopped');
resolve();
});
} else {
resolve();
}
});
}
public getActualPort(): number {
return this.actualPort || this.config.port;
}
public getProxyUrl(): string {
return `http://${this.config.host}:${this.getActualPort()}`;
}
}
// Convenience functions
export function createProxyServer(config?: ProxyConfig): AudioProxyServer {
return new AudioProxyServer(config);
}
export async function startProxyServer(
config?: ProxyConfig
): Promise<AudioProxyServer> {
const server = createProxyServer(config);
await server.start();
return server;
}