import http from 'http';
import https from 'https';
import path from 'path';
import os from 'os';
import { createReadStream, existsSync, statSync, readFileSync, mkdirSync, readdirSync, openSync, writeSync, closeSync, fstatSync, constants } from 'fs';
function escapeHtml( str ) {
return str
.replace( /&/g, '&' )
.replace( //g, '>' )
.replace( /"/g, '"' );
}
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.ogg': 'audio/ogg',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.glb': 'model/gltf-binary',
'.gltf': 'model/gltf+json',
'.hdr': 'application/octet-stream',
'.exr': 'application/octet-stream',
'.fbx': 'application/octet-stream',
'.bin': 'application/octet-stream',
'.cube': 'text/plain',
'.wasm': 'application/wasm',
'.ktx2': 'image/ktx2'
};
function createHandler( rootDirectory ) {
return ( req, res ) => {
const pathname = decodeURIComponent( req.url.split( '?' )[ 0 ] );
let filePath = path.join( rootDirectory, pathname );
// Prevent path traversal attacks
if ( ! filePath.startsWith( rootDirectory ) ) {
res.writeHead( 403 );
res.end( 'Forbidden' );
return;
}
// Handle directories
if ( existsSync( filePath ) && statSync( filePath ).isDirectory() ) {
const indexPath = path.join( filePath, 'index.html' );
if ( existsSync( indexPath ) ) {
filePath = indexPath;
} else {
// Show directory listing
const files = readdirSync( filePath )
.filter( f => ! f.startsWith( '.' ) )
.sort( ( a, b ) => {
const aIsDir = statSync( path.join( filePath, a ) ).isDirectory();
const bIsDir = statSync( path.join( filePath, b ) ).isDirectory();
if ( aIsDir && ! bIsDir ) return - 1;
if ( ! aIsDir && bIsDir ) return 1;
return a.localeCompare( b );
} );
const base = pathname.endsWith( '/' ) ? pathname : pathname + '/';
const items = files.map( file => {
const fullPath = path.join( filePath, file );
const isDir = statSync( fullPath ).isDirectory();
const safeFile = escapeHtml( file );
const safeHref = escapeHtml( base + file + ( isDir ? '/' : '' ) );
const icon = isDir ? '📁' : '📄';
return `${icon}${safeFile}`;
} ).join( '\n' );
const safePath = escapeHtml( pathname );
const html = `
Index of ${safePath}
Index of ${safePath}
${pathname !== '/' ? '📁..' : ''}
${items}
`;
res.writeHead( 200, { 'Content-Type': 'text/html' } );
res.end( html );
return;
}
}
if ( ! existsSync( filePath ) ) {
res.writeHead( 404 );
res.end( 'File not found' );
return;
}
const ext = path.extname( filePath ).toLowerCase();
const contentType = mimeTypes[ ext ] || 'application/octet-stream';
const stat = statSync( filePath );
const fileSize = stat.size;
const range = req.headers.range;
if ( range ) {
const parts = range.replace( /bytes=/, '' ).split( '-' );
const start = parseInt( parts[ 0 ], 10 );
const end = parts[ 1 ] ? parseInt( parts[ 1 ], 10 ) : fileSize - 1;
res.writeHead( 206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': contentType
} );
createReadStream( filePath, { start, end } ).pipe( res );
} else {
res.writeHead( 200, {
'Content-Length': fileSize,
'Content-Type': contentType
} );
createReadStream( filePath ).pipe( res );
}
};
}
function getCacheDir() {
const appName = 'three-dev-server';
if ( process.platform === 'darwin' ) {
return path.join( os.homedir(), 'Library', 'Application Support', appName );
} else if ( process.platform === 'win32' ) {
return path.join( process.env.LOCALAPPDATA || process.env.APPDATA, appName );
} else {
return path.join( os.homedir(), '.config', appName );
}
}
async function getCertificate() {
// Cache certificate in platform-specific data directory
const cacheDir = getCacheDir();
const certPath = path.join( cacheDir, 'cert.pem' );
const keyPath = path.join( cacheDir, 'key.pem' );
// Try to use cached certificate (valid for 7 days)
try {
const certFd = openSync( certPath, constants.O_RDONLY );
const stat = fstatSync( certFd );
const age = Date.now() - stat.mtimeMs;
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
if ( age < maxAge ) {
const cert = readFileSync( certFd, 'utf8' );
closeSync( certFd );
const key = readFileSync( keyPath, 'utf8' );
return { cert, key };
}
closeSync( certFd );
} catch ( e ) {
// Cache miss or invalid, generate new certificate
}
// Generate new self-signed certificate using selfsigned
let selfsigned;
try {
selfsigned = ( await import( 'selfsigned' ) ).default;
} catch ( e ) {
console.error( 'For HTTPS support, install selfsigned: npm install selfsigned' );
process.exit( 1 );
}
const attrs = [ { name: 'commonName', value: 'localhost' } ];
const pems = await selfsigned.generate( attrs, {
algorithm: 'sha256',
days: 30,
keySize: 2048,
extensions: [
{ name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
{ name: 'subjectAltName', altNames: [
{ type: 2, value: 'localhost' },
{ type: 7, ip: '127.0.0.1' }
] }
]
} );
// Cache the certificate with restrictive permissions
try {
mkdirSync( cacheDir, { recursive: true, mode: 0o700 } );
const certFd = openSync( certPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600 );
writeSync( certFd, pems.cert );
closeSync( certFd );
const keyFd = openSync( keyPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600 );
writeSync( keyFd, pems.private );
closeSync( keyFd );
} catch ( e ) {
// Caching failed, but certificate is still valid for this session
}
return { cert: pems.cert, key: pems.private };
}
export function createServer( options = {} ) {
const rootDirectory = options.root || path.resolve();
const handler = createHandler( rootDirectory );
return http.createServer( handler );
}
function tryListen( server, port, maxAttempts = 20 ) {
return new Promise( ( resolve, reject ) => {
let attempts = 0;
const onError = ( err ) => {
if ( err.code === 'EADDRINUSE' && attempts < maxAttempts ) {
attempts ++;
server.listen( port + attempts );
} else {
reject( err );
}
};
const onListening = () => {
server.off( 'error', onError );
resolve( server.address().port );
};
server.once( 'error', onError );
server.once( 'listening', onListening );
server.listen( port );
} );
}
// CLI mode
const isMain = process.argv[ 1 ] && path.resolve( process.argv[ 1 ] ) === path.resolve( import.meta.url.replace( 'file://', '' ) );
if ( isMain ) {
const args = process.argv.slice( 2 );
const requestedPort = parseInt( args.find( ( _, i, arr ) => arr[ i - 1 ] === '-p' ) || '8080', 10 );
const useSSL = args.includes( '--ssl' );
const rootDirectory = path.resolve();
const protocol = useSSL ? 'https' : 'http';
const handler = createHandler( rootDirectory );
let server;
if ( useSSL ) {
const credentials = await getCertificate();
server = https.createServer( credentials, handler );
} else {
server = http.createServer( handler );
}
const port = await tryListen( server, requestedPort );
if ( port !== requestedPort ) {
console.log( `\x1b[33mPort ${requestedPort} in use, using ${port} instead.\x1b[0m` );
}
console.log( `\x1b[32mServer running at ${protocol}://localhost:${port}/\x1b[0m` );
// Show network addresses
const interfaces = os.networkInterfaces();
for ( const name of Object.keys( interfaces ) ) {
for ( const net of interfaces[ name ] ) {
if ( net.family === 'IPv4' && ! net.internal ) {
console.log( ` ${protocol}://${net.address}:${port}/` );
}
}
}
console.log( '\nPress Ctrl+C to stop.' );
process.on( 'SIGINT', () => {
console.log( '\nShutting down...' );
server.close();
process.exit( 0 );
} );
}