desktop-audio-proxy
Version:
A comprehensive audio streaming solution for Tauri and Electron apps that bypasses CORS and WebKit codec issues
354 lines (350 loc) • 14.6 kB
JavaScript
;
var express = require('express');
var cors = require('cors');
var axios = require('axios');
var net = require('net');
// Utility function to check if a port is available
async function isPortAvailable(port, host = 'localhost') {
return new Promise(resolve => {
const server = net.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, host = 'localhost', maxAttempts = 10) {
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}`);
}
class AudioProxyServer {
constructor(config = {}) {
this.server = null;
this.actualPort = 0;
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();
}
setupMiddleware() {
// 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, res, next) => {
console.log(`[AudioProxy] ${req.method} ${req.path}`);
next();
});
}
}
setupRoutes() {
// Handle CORS preflight for all routes
this.app.options('*', (req, res) => {
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, res) => {
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, res) => {
const url = req.query.url;
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) {
console.error('[AudioProxy] Info error:', error);
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error;
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, res) => {
const url = req.query.url;
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 = {
'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 = 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;
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) {
console.error('[AudioProxy] Proxy error:', error);
if (!res.headersSent) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error;
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;
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,
});
}
}
}
});
}
async start() {
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) => {
reject(error);
});
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to start proxy server: ${errorMessage}`);
}
}
async stop() {
return new Promise(resolve => {
if (this.server) {
this.server.close(() => {
console.log('Desktop Audio Proxy stopped');
resolve();
});
}
else {
resolve();
}
});
}
getActualPort() {
return this.actualPort || this.config.port;
}
getProxyUrl() {
return `http://${this.config.host}:${this.getActualPort()}`;
}
}
// Convenience functions
function createProxyServer(config) {
return new AudioProxyServer(config);
}
async function startProxyServer(config) {
const server = createProxyServer(config);
await server.start();
return server;
}
exports.AudioProxyServer = AudioProxyServer;
exports.createProxyServer = createProxyServer;
exports.startProxyServer = startProxyServer;
//# sourceMappingURL=server.js.map