UNPKG

servatron

Version:

Create a handler that can server static files using the NodeJS http/http2 modules, or use the inbuilt cli server to quickly run a web server.

201 lines (200 loc) 8.41 kB
import fs from 'node:fs'; import path from 'node:path'; import mime from 'mime'; import { minimatch } from 'minimatch'; import { PathType, getPathInfo } from './getPathInfo.js'; import { searchDirectoriesForPath } from './searchDirectoriesForPath.js'; import generateAntiCorsHeaders from './generateAntiCorsHeaders.js'; // Removed duplicate interface definition function send404(options, stream, headers) { const antiCorsHeaders = options.antiCors ? generateAntiCorsHeaders(headers) : null; if (options.spa && options.spaIndex) { // Check if a resolver matches the spaIndex if (options.resolvers) { for (const pattern in options.resolvers) { if (minimatch(options.spaIndex, pattern)) { const resolver = options.resolvers[pattern]; fs.readFile(options.spaIndex, async (err, data) => { if (err) { console.error(`Error reading spaIndex file ${options.spaIndex}:`, err); stream.respond({ ...antiCorsHeaders, 'content-type': 'text/plain', ':status': 500 }); stream.end('Internal Server Error'); return; } try { if (antiCorsHeaders) { stream.additionalHeaders(antiCorsHeaders); } await resolver(options.spaIndex, data, stream); } catch (error) { console.error('Error in SPA resolver:', error); stream.respond({ ...antiCorsHeaders, 'content-type': 'text/plain', ':status': 500 }); stream.end('Internal Server Error'); } }); return; } } } // No resolver matched or no resolvers defined, serve SPA index directly stream.respond({ ...antiCorsHeaders, 'content-type': mime.getType(options.spaIndex) || 'application/octet-stream', ':status': 200 }); fs.createReadStream(options.spaIndex).pipe(stream); return; } stream.respond({ ...antiCorsHeaders, 'content-type': 'text/plain', ':status': 404 }); stream.end('404 - not found'); } /** * Create a handler that will respond to a request * with the response from a static file lookup. **/ const servatron = (optionsInput) => { const options = { directory: process.cwd(), ...optionsInput, }; // Ensure directory is always a non-empty array or a string. if (!options.directory || (Array.isArray(options.directory) && options.directory.length === 0)) { options.directory = process.cwd(); } const directories = Array.isArray(options.directory) ? options.directory : [options.directory]; // Determine the base path for SPA index, ensuring it's a string. let spaBasePath; if (Array.isArray(options.directory)) { spaBasePath = options.directory[0]; // Must have at least one element due to the check above } else { spaBasePath = options.directory; // It's a string } if (options.spa) { const spaIndexFile = options.spaIndex || 'index.html'; options.spaIndex = path.join(spaBasePath, spaIndexFile); getPathInfo(options.spaIndex).then(pathInfo => { if (pathInfo !== PathType.File) { console.log(`--spa mode will not work as index file (${options.spaIndex}) not found`); } }); } return async (stream, headers) => { let decodedPath; try { // Extract just the path part without query string const urlPath = headers[':path']?.split('?')[0] || ''; decodedPath = decodeURIComponent(urlPath); } catch (error) { send404(options, stream, headers); return; } const normalizedPath = path.normalize('/' + decodedPath); const found = await searchDirectoriesForPath(directories, normalizedPath.slice(1)); if (!found) { send404(options, stream, headers); return; } let filePath = found.filePath; // Handle directory request if (found.filePathType === PathType.Directory) { let indexFilePath = null; // Check for index files if configured if (options.index && options.index.length > 0) { for (const indexFile of options.index) { const testPath = path.join(filePath, indexFile); try { const stats = await fs.promises.stat(testPath); if (stats.isFile()) { indexFilePath = testPath; break; } } catch (error) { // File doesn't exist, continue to next } } } else { // Default to index.html const defaultPath = path.join(filePath, 'index.html'); try { const stats = await fs.promises.stat(defaultPath); if (stats.isFile()) { indexFilePath = defaultPath; } } catch (error) { // index.html not found } } // If no index file found, send 404 if (!indexFilePath) { send404(options, stream, headers); return; } // Update the file path to the found index file filePath = indexFilePath; } const antiCorsHeaders = options.antiCors ? generateAntiCorsHeaders(headers) : null; const contentType = mime.getType(filePath) || 'application/octet-stream'; // Only adjust content type for index files that don't have a recognized mime type let adjustedContentType = contentType; if (contentType === 'application/octet-stream' && options.index && options.index.length > 0) { // Check if this is one of our configured index files const fileName = path.basename(filePath); if (options.index.includes(fileName)) { // This is a configured index file with no recognized mime type, use text/html adjustedContentType = 'text/html'; } } if (options.resolvers) { let resolverMatched = false; for (const pattern in options.resolvers) { if (minimatch(filePath, pattern)) { resolverMatched = true; const resolver = options.resolvers[pattern]; try { const data = await fs.promises.readFile(filePath); for (const [headerKey, headerValue] of Object.entries(antiCorsHeaders || {})) { stream.additionalHeaders({ [headerKey]: headerValue }); } await resolver(filePath, data, stream); } catch (error) { console.error('Error in resolver:', error); send404(options, stream, headers); } return; } } if (!resolverMatched) { // No resolver matched, proceed with default behavior stream.respond({ ...antiCorsHeaders, 'content-type': adjustedContentType, ':status': 200 }); fs.createReadStream(filePath).pipe(stream); } } else { // No resolvers specified, proceed with default behavior stream.respond({ ...antiCorsHeaders, 'content-type': adjustedContentType, ':status': 200 }); fs.createReadStream(filePath).pipe(stream); } }; }; export default servatron;