kist
Version:
Package Pipeline Processor
312 lines (271 loc) • 10.5 kB
text/typescript
// ============================================================================
// Import
// ============================================================================
import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { Server } from "http";
import path from "path";
import { WebSocket, WebSocketServer } from "ws";
import { AbstractProcess } from "../core/abstract/AbstractProcess";
import { ConfigStore } from "../core/config/ConfigStore";
import { LiveOptionsInterface } from "../interface";
import { OptionsInterface } from "../interface/OptionsInterface";
// ============================================================================
// Class
// ============================================================================
/**
* LiveServer class provides functionality to serve static files,
* inject live reload scripts into HTML responses, and manage WebSocket
* connections to enable live reload capabilities.
*/
export class LiveServer extends AbstractProcess {
// Parameters
// ========================================================================
/**
* Express application
*/
private app = express();
/**
* The underlying HTTP server used by the LiveServer.
* Handles incoming HTTP requests and serves static files.
*/
private server: Server;
/**
* The WebSocket server responsible for managing WebSocket connections.
* Enables real-time communication with connected clients.
*/
private wss: WebSocketServer;
/**
* A set of WebSocket clients currently connected to the server.
* Each client represents an active WebSocket connection.
*/
private clients: Set<WebSocket> = new Set();
/**
* The port number on which the server is running.
* Defaults to 3000 if not specified in the configuration.
*/
private port: number;
/**
* The root directory from which static files are served.
* Defaults to the "public" folder in the current working directory.
*/
private root: string;
/**
* An array of paths to watch for changes.
* When a file within these paths changes, the server triggers live reload.
*/
private watchPaths: string[];
/**
* An array of paths or patterns to ignore during file watching.
* Prevents unnecessary reloads caused by changes in these paths.
* Defaults to ignoring the "node_modules" directory.
*/
private ignoredPaths: string[];
// Constructor
// ========================================================================
/**
* Initializes the LiveServer.
// * @param port - The port on which the server will listen.
*/
constructor() {
super();
const configStore = ConfigStore.getInstance();
const liveReloadOptions: LiveOptionsInterface =
configStore.get<OptionsInterface["live"]>("options.live") || {};
// Extract and apply live reload options with defaults
this.port = liveReloadOptions.port || 3000;
this.root = path.resolve(
process.cwd(),
liveReloadOptions.root || "public",
);
this.watchPaths = (
liveReloadOptions.watchPaths || [
"src/**/*",
"config/**/*",
"pack.yaml",
]
).map((p: string) => path.resolve(process.cwd(), p));
this.ignoredPaths = (
liveReloadOptions.ignoredPaths || ["node_modules"]
).map((p: string) => path.resolve(process.cwd(), p));
// Log initialization details
this.logInitializationDetails();
// Initialize server
// this.initializeServer();
// Start the HTTP server
this.server = this.app.listen(this.port, () => {
this.logInfo(
`Live Server running at http://localhost:${this.port}`,
);
});
// Initialize WebSocket server
this.wss = new WebSocketServer({ server: this.server });
// Set up rate limiting
this.setupRateLimiter();
// Set up WebSocket handlers
this.setupWebSocketHandlers();
// Set up middleware
this.setupMiddleware();
}
// Methods
// ========================================================================
/**
* Initializes the HTTP server and WebSocket server.
*/
private initializeServer(): void {
// Start the HTTP server
this.server = this.app.listen(this.port, () => {
this.logInfo(
`Live Server running at http://localhost:${this.port}`,
);
});
// Initialize WebSocket server
this.wss = new WebSocketServer({ server: this.server });
}
/** Logs initialization details for the LiveServer. */
private logInitializationDetails(): void {
this.logInfo(`LiveServer initialized with port: ${this.port}`);
this.logInfo(`Serving static files from: ${this.root}`);
this.logInfo(`Watching paths: ${JSON.stringify(this.watchPaths)}`);
this.logInfo(`Ignoring paths: ${JSON.stringify(this.ignoredPaths)}`);
}
/**
* Sets up rate limiting middleware to prevent abuse of HTTP requests.
*/
private setupRateLimiter(): void {
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP, please try again later.",
});
this.app.use(limiter);
}
/**
* Sets up WebSocket handlers to manage client connections.
*/
private setupWebSocketHandlers(): void {
this.wss.on("connection", (ws: WebSocket) => {
this.logInfo("New WebSocket connection established.");
this.clients.add(ws);
ws.on("message", (message) => {
this.logInfo(
`WebSocket message received: ${message.toString()}`,
);
});
ws.on("close", () => {
this.logInfo("WebSocket connection closed.");
this.clients.delete(ws);
});
ws.on("error", (error) => {
console.error("WebSocket encountered an error:", error);
this.clients.delete(ws);
});
});
}
/**
* Sets up middleware for serving static files and injecting the live
* reload script into HTML files.
*/
private setupMiddleware(): void {
// Securely serve static files from the "public" directory
// const publicPath = path.resolve(
// __dirname,
// "public"
// );
this.logInfo(`Resolved public directory: ${this.root}`);
this.logInfo(`Serving static files from: ${this.root}`);
this.app.use(express.static(this.root));
// Middleware to inject the live reload script into HTML files
this.app.use(this.injectLiveReloadScript.bind(this));
}
/**
* Middleware function to inject the live reload script into HTML
* responses. Prevents directory traversal attacks by sanitizing the
* requested file path.
* @param req - The HTTP request object.
* @param res - The HTTP response object.
* @param next - The next middleware function.
*/
private injectLiveReloadScript(
req: Request,
res: Response,
next: NextFunction,
): void {
if (req.url.endsWith(".html")) {
const sanitizedPath = path.join(
path.resolve(__dirname, "public"),
// Prevent directory traversal
path.normalize(req.url).replace(/^(\.\.(\/|\\|$))+/g, ""),
);
res.sendFile(sanitizedPath, (err) => {
if (err) {
console.error("Error sending HTML file:", err);
next(err);
} else {
res.write(
`<script>
const ws = new WebSocket("ws://localhost:${this.port}");
ws.onmessage = (event) => {
if (event.data === "reload") {
this.logInfo("Reloading page...");
window.location.reload();
}
};
</script>`,
);
res.end();
}
});
} else {
next();
}
}
/**
* Sends a reload signal to all connected WebSocket clients.
*/
public reloadClients(): void {
this.logInfo("Reloading all connected clients...");
this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send("reload");
}
});
}
/**
* Gracefully shuts down the server and all WebSocket connections.
*/
public async shutdown(): Promise<void> {
this.logInfo("Shutting down Live Reload Server...");
this.clients.forEach((client) => client.close());
this.wss.close();
await new Promise<void>((resolve, reject) => {
this.server.close((err) => {
if (err) {
if (
this.isErrnoException(err) &&
err.code === "ERR_SERVER_NOT_RUNNING"
) {
this.logWarn(
"Server is not running, skipping shutdown.",
);
resolve();
} else {
this.logError("Error shutting down server:", err);
reject(err);
}
} else {
resolve();
}
});
});
this.logInfo("Live Reload Server has been shut down.");
}
/**
* Type guard to check if an error is an instance of NodeJS.ErrnoException.
* @param error - The error to check.
* @returns True if the error has a `code` property.
*/
private isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error;
}
}