server.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import http from 'http';
  2. import https from 'https';
  3. import path from 'path';
  4. import os from 'os';
  5. import { createReadStream, existsSync, statSync, readFileSync, mkdirSync, readdirSync, openSync, writeSync, closeSync, fstatSync, constants } from 'fs';
  6. function escapeHtml( str ) {
  7. return str
  8. .replace( /&/g, '&' )
  9. .replace( /</g, '&lt;' )
  10. .replace( />/g, '&gt;' )
  11. .replace( /"/g, '&quot;' );
  12. }
  13. const mimeTypes = {
  14. '.html': 'text/html',
  15. '.js': 'application/javascript',
  16. '.css': 'text/css',
  17. '.json': 'application/json',
  18. '.png': 'image/png',
  19. '.jpg': 'image/jpeg',
  20. '.gif': 'image/gif',
  21. '.svg': 'image/svg+xml',
  22. '.mp3': 'audio/mpeg',
  23. '.mp4': 'video/mp4',
  24. '.webm': 'video/webm',
  25. '.ogv': 'video/ogg',
  26. '.ogg': 'audio/ogg',
  27. '.woff': 'font/woff',
  28. '.woff2': 'font/woff2',
  29. '.ttf': 'font/ttf',
  30. '.glb': 'model/gltf-binary',
  31. '.gltf': 'model/gltf+json',
  32. '.hdr': 'application/octet-stream',
  33. '.exr': 'application/octet-stream',
  34. '.fbx': 'application/octet-stream',
  35. '.bin': 'application/octet-stream',
  36. '.cube': 'text/plain',
  37. '.wasm': 'application/wasm',
  38. '.ktx2': 'image/ktx2'
  39. };
  40. function createHandler( rootDirectory ) {
  41. return ( req, res ) => {
  42. const pathname = decodeURIComponent( req.url.split( '?' )[ 0 ] );
  43. let filePath = path.join( rootDirectory, pathname );
  44. // Prevent path traversal attacks
  45. if ( ! filePath.startsWith( rootDirectory ) ) {
  46. res.writeHead( 403 );
  47. res.end( 'Forbidden' );
  48. return;
  49. }
  50. // Handle directories
  51. if ( existsSync( filePath ) && statSync( filePath ).isDirectory() ) {
  52. const indexPath = path.join( filePath, 'index.html' );
  53. if ( existsSync( indexPath ) ) {
  54. filePath = indexPath;
  55. } else {
  56. // Show directory listing
  57. const files = readdirSync( filePath )
  58. .filter( f => ! f.startsWith( '.' ) )
  59. .sort( ( a, b ) => {
  60. const aIsDir = statSync( path.join( filePath, a ) ).isDirectory();
  61. const bIsDir = statSync( path.join( filePath, b ) ).isDirectory();
  62. if ( aIsDir && ! bIsDir ) return - 1;
  63. if ( ! aIsDir && bIsDir ) return 1;
  64. return a.localeCompare( b );
  65. } );
  66. const base = pathname.endsWith( '/' ) ? pathname : pathname + '/';
  67. const items = files.map( file => {
  68. const fullPath = path.join( filePath, file );
  69. const isDir = statSync( fullPath ).isDirectory();
  70. const safeFile = escapeHtml( file );
  71. const safeHref = escapeHtml( base + file + ( isDir ? '/' : '' ) );
  72. const icon = isDir ? '📁' : '📄';
  73. return `<a href="${safeHref}"><span class="i">${icon}</span>${safeFile}</a>`;
  74. } ).join( '\n' );
  75. const safePath = escapeHtml( pathname );
  76. const html = `<!DOCTYPE html>
  77. <html>
  78. <head>
  79. <meta charset="utf-8">
  80. <meta name="viewport" content="width=device-width">
  81. <meta name="color-scheme" content="light dark">
  82. <title>Index of ${safePath}</title>
  83. <style>
  84. body { font-family: system-ui, sans-serif; margin: 20px; }
  85. h1 { font-weight: normal; font-size: 1.2em; margin-bottom: 10px; }
  86. a { display: block; padding: 6px 8px; color: inherit; text-decoration: none; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  87. a:hover { background: rgba(128,128,128,0.2); }
  88. .i { display: inline-block; width: 1.5em; }
  89. </style>
  90. </head>
  91. <body>
  92. <h1>Index of ${safePath}</h1>
  93. ${pathname !== '/' ? '<a href="../"><span class="i">📁</span>..</a>' : ''}
  94. ${items}
  95. </body>
  96. </html>`;
  97. res.writeHead( 200, { 'Content-Type': 'text/html' } );
  98. res.end( html );
  99. return;
  100. }
  101. }
  102. if ( ! existsSync( filePath ) ) {
  103. res.writeHead( 404 );
  104. res.end( 'File not found' );
  105. return;
  106. }
  107. const ext = path.extname( filePath ).toLowerCase();
  108. const contentType = mimeTypes[ ext ] || 'application/octet-stream';
  109. const stat = statSync( filePath );
  110. const fileSize = stat.size;
  111. const range = req.headers.range;
  112. if ( range ) {
  113. const parts = range.replace( /bytes=/, '' ).split( '-' );
  114. const start = parseInt( parts[ 0 ], 10 );
  115. const end = parts[ 1 ] ? parseInt( parts[ 1 ], 10 ) : fileSize - 1;
  116. res.writeHead( 206, {
  117. 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
  118. 'Accept-Ranges': 'bytes',
  119. 'Content-Length': end - start + 1,
  120. 'Content-Type': contentType
  121. } );
  122. createReadStream( filePath, { start, end } ).pipe( res );
  123. } else {
  124. res.writeHead( 200, {
  125. 'Content-Length': fileSize,
  126. 'Content-Type': contentType
  127. } );
  128. createReadStream( filePath ).pipe( res );
  129. }
  130. };
  131. }
  132. function getCacheDir() {
  133. const appName = 'three-dev-server';
  134. if ( process.platform === 'darwin' ) {
  135. return path.join( os.homedir(), 'Library', 'Application Support', appName );
  136. } else if ( process.platform === 'win32' ) {
  137. return path.join( process.env.LOCALAPPDATA || process.env.APPDATA, appName );
  138. } else {
  139. return path.join( os.homedir(), '.config', appName );
  140. }
  141. }
  142. async function getCertificate() {
  143. // Cache certificate in platform-specific data directory
  144. const cacheDir = getCacheDir();
  145. const certPath = path.join( cacheDir, 'cert.pem' );
  146. const keyPath = path.join( cacheDir, 'key.pem' );
  147. // Try to use cached certificate (valid for 7 days)
  148. try {
  149. const certFd = openSync( certPath, constants.O_RDONLY );
  150. const stat = fstatSync( certFd );
  151. const age = Date.now() - stat.mtimeMs;
  152. const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
  153. if ( age < maxAge ) {
  154. const cert = readFileSync( certFd, 'utf8' );
  155. closeSync( certFd );
  156. const key = readFileSync( keyPath, 'utf8' );
  157. return { cert, key };
  158. }
  159. closeSync( certFd );
  160. } catch ( e ) {
  161. // Cache miss or invalid, generate new certificate
  162. }
  163. // Generate new self-signed certificate using selfsigned
  164. let selfsigned;
  165. try {
  166. selfsigned = ( await import( 'selfsigned' ) ).default;
  167. } catch ( e ) {
  168. console.error( 'For HTTPS support, install selfsigned: npm install selfsigned' );
  169. process.exit( 1 );
  170. }
  171. const attrs = [ { name: 'commonName', value: 'localhost' } ];
  172. const pems = await selfsigned.generate( attrs, {
  173. algorithm: 'sha256',
  174. days: 30,
  175. keySize: 2048,
  176. extensions: [
  177. { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
  178. { name: 'subjectAltName', altNames: [
  179. { type: 2, value: 'localhost' },
  180. { type: 7, ip: '127.0.0.1' }
  181. ] }
  182. ]
  183. } );
  184. // Cache the certificate with restrictive permissions
  185. try {
  186. mkdirSync( cacheDir, { recursive: true, mode: 0o700 } );
  187. const certFd = openSync( certPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600 );
  188. writeSync( certFd, pems.cert );
  189. closeSync( certFd );
  190. const keyFd = openSync( keyPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600 );
  191. writeSync( keyFd, pems.private );
  192. closeSync( keyFd );
  193. } catch ( e ) {
  194. // Caching failed, but certificate is still valid for this session
  195. }
  196. return { cert: pems.cert, key: pems.private };
  197. }
  198. export function createServer( options = {} ) {
  199. const rootDirectory = options.root || path.resolve();
  200. const handler = createHandler( rootDirectory );
  201. return http.createServer( handler );
  202. }
  203. function tryListen( server, port, maxAttempts = 20 ) {
  204. return new Promise( ( resolve, reject ) => {
  205. let attempts = 0;
  206. const onError = ( err ) => {
  207. if ( err.code === 'EADDRINUSE' && attempts < maxAttempts ) {
  208. attempts ++;
  209. server.listen( port + attempts );
  210. } else {
  211. reject( err );
  212. }
  213. };
  214. const onListening = () => {
  215. server.off( 'error', onError );
  216. resolve( server.address().port );
  217. };
  218. server.once( 'error', onError );
  219. server.once( 'listening', onListening );
  220. server.listen( port );
  221. } );
  222. }
  223. // CLI mode
  224. const isMain = process.argv[ 1 ] && path.resolve( process.argv[ 1 ] ) === path.resolve( import.meta.url.replace( 'file://', '' ) );
  225. if ( isMain ) {
  226. const args = process.argv.slice( 2 );
  227. const requestedPort = parseInt( args.find( ( _, i, arr ) => arr[ i - 1 ] === '-p' ) || '8080', 10 );
  228. const useSSL = args.includes( '--ssl' );
  229. const rootDirectory = path.resolve();
  230. const protocol = useSSL ? 'https' : 'http';
  231. const handler = createHandler( rootDirectory );
  232. let server;
  233. if ( useSSL ) {
  234. const credentials = await getCertificate();
  235. server = https.createServer( credentials, handler );
  236. } else {
  237. server = http.createServer( handler );
  238. }
  239. const port = await tryListen( server, requestedPort );
  240. if ( port !== requestedPort ) {
  241. console.log( `\x1b[33mPort ${requestedPort} in use, using ${port} instead.\x1b[0m` );
  242. }
  243. console.log( `\x1b[32mServer running at ${protocol}://localhost:${port}/\x1b[0m` );
  244. // Show network addresses
  245. const interfaces = os.networkInterfaces();
  246. for ( const name of Object.keys( interfaces ) ) {
  247. for ( const net of interfaces[ name ] ) {
  248. if ( net.family === 'IPv4' && ! net.internal ) {
  249. console.log( ` ${protocol}://${net.address}:${port}/` );
  250. }
  251. }
  252. }
  253. console.log( '\nPress Ctrl+C to stop.' );
  254. process.on( 'SIGINT', () => {
  255. console.log( '\nShutting down...' );
  256. server.close();
  257. process.exit( 0 );
  258. } );
  259. }
粤ICP备19079148号