Merge pull request #17684 from timvandermeij/modernize-webserver-pt2

Modernize the webserver code (part 2)
This commit is contained in:
Tim van der Meij 2024-02-17 18:22:51 +01:00 committed by GitHub
commit 8487c67cb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 128 deletions

View File

@ -2068,8 +2068,7 @@ gulp.task(
console.log("### Starting local server"); console.log("### Starting local server");
const { WebServer } = await import("./test/webserver.mjs"); const { WebServer } = await import("./test/webserver.mjs");
const server = new WebServer(); const server = new WebServer({ port: 8888 });
server.port = 8888;
server.start(); server.start();
} }
) )

View File

@ -1015,11 +1015,12 @@ async function startBrowsers({ baseUrl, initializeSession }) {
} }
function startServer() { function startServer() {
server = new WebServer(); server = new WebServer({
server.host = host; root: "..",
server.port = options.port; host,
server.root = ".."; port: options.port,
server.cacheExpirationTime = 3600; cacheExpirationTime: 3600,
});
server.start(); server.start();
} }

View File

@ -13,14 +13,13 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
/* eslint-disable no-var */
import fs from "fs"; import fs from "fs";
import fsPromises from "fs/promises"; import fsPromises from "fs/promises";
import http from "http"; import http from "http";
import path from "path"; import path from "path";
var mimeTypes = { const MIME_TYPES = {
".css": "text/css", ".css": "text/css",
".html": "text/html", ".html": "text/html",
".js": "application/javascript", ".js": "application/javascript",
@ -36,17 +35,16 @@ var mimeTypes = {
".bcmap": "application/octet-stream", ".bcmap": "application/octet-stream",
".ftl": "text/plain", ".ftl": "text/plain",
}; };
const DEFAULT_MIME_TYPE = "application/octet-stream";
var defaultMimeType = "application/octet-stream";
class WebServer { class WebServer {
constructor() { constructor({ root, host, port, cacheExpirationTime }) {
this.root = "."; this.root = root || ".";
this.host = "localhost"; this.host = host || "localhost";
this.port = 0; this.port = port || 0;
this.server = null; this.server = null;
this.verbose = false; this.verbose = false;
this.cacheExpirationTime = 0; this.cacheExpirationTime = cacheExpirationTime || 0;
this.disableRangeRequests = false; this.disableRangeRequests = false;
this.hooks = { this.hooks = {
GET: [crossOriginHandler], GET: [crossOriginHandler],
@ -58,9 +56,7 @@ class WebServer {
this.#ensureNonZeroPort(); this.#ensureNonZeroPort();
this.server = http.createServer(this.#handler.bind(this)); this.server = http.createServer(this.#handler.bind(this));
this.server.listen(this.port, this.host, callback); this.server.listen(this.port, this.host, callback);
console.log( console.log(`Server running at http://${this.host}:${this.port}/`);
"Server running at http://" + this.host + ":" + this.port + "/"
);
} }
stop(callback) { stop(callback) {
@ -71,120 +67,118 @@ class WebServer {
#ensureNonZeroPort() { #ensureNonZeroPort() {
if (!this.port) { if (!this.port) {
// If port is 0, a random port will be chosen instead. Do not set a host // If port is 0, a random port will be chosen instead. Do not set a host
// name to make sure that the port is synchronously set by .listen(). // name to make sure that the port is synchronously set by `.listen()`.
var server = http.createServer().listen(0); const server = http.createServer().listen(0);
var address = server.address(); const address = server.address();
// .address().port being available synchronously is merely an // `.address().port` being available synchronously is merely an
// implementation detail. So we are defensive here and fall back to some // implementation detail, so we are defensive here and fall back to a
// fixed port when the address is not available yet. // fixed port when the address is not available yet.
this.port = address ? address.port : 8000; this.port = address ? address.port : 8000;
server.close(); server.close();
} }
} }
#handler(req, res) { async #handler(request, response) {
var self = this; // Validate and parse the request URL.
var url = req.url.replaceAll("//", "/"); const url = request.url.replaceAll("//", "/");
var urlParts = /([^?]*)((?:\?(.*))?)/.exec(url); const urlParts = /([^?]*)((?:\?(.*))?)/.exec(url);
let pathPart;
try { try {
// Guard against directory traversal attacks such as // Guard against directory traversal attacks such as
// `/../../../../../../../etc/passwd`, which let you make GET requests // `/../../../../../../../etc/passwd`, which let you make GET requests
// for files outside of `this.root`. // for files outside of `this.root`.
var pathPart = path.normalize(decodeURI(urlParts[1])); pathPart = path.normalize(decodeURI(urlParts[1]));
// path.normalize returns a path on the basis of the current platform. // `path.normalize` returns a path on the basis of the current platform.
// Windows paths cause issues in statFile and serverDirectoryIndex. // Windows paths cause issues in `checkRequest` and underlying methods.
// Converting to unix path would avoid platform checks in said functions. // Converting to a Unix path avoids platform checks in said functions.
pathPart = pathPart.replaceAll("\\", "/"); pathPart = pathPart.replaceAll("\\", "/");
} catch { } catch {
// If the URI cannot be decoded, a `URIError` is thrown. This happens for // If the URI cannot be decoded, a `URIError` is thrown. This happens for
// malformed URIs such as `http://localhost:8888/%s%s` and should be // malformed URIs such as `http://localhost:8888/%s%s` and should be
// handled as a bad request. // handled as a bad request.
res.writeHead(400); response.writeHead(400);
res.end("Bad request", "utf8"); response.end("Bad request", "utf8");
return; return;
} }
var queryPart = urlParts[3];
var verbose = this.verbose;
var methodHooks = this.hooks[req.method]; // Validate the request method and execute method hooks.
const methodHooks = this.hooks[request.method];
if (!methodHooks) { if (!methodHooks) {
res.writeHead(405); response.writeHead(405);
res.end("Unsupported request method", "utf8"); response.end("Unsupported request method", "utf8");
return; return;
} }
var handled = methodHooks.some(function (hook) { const handled = methodHooks.some(hook => hook(request, response));
return hook(req, res);
});
if (handled) { if (handled) {
return; return;
} }
// Check the request and serve the file/folder contents.
if (pathPart === "/favicon.ico") { if (pathPart === "/favicon.ico") {
fs.realpath( pathPart = "test/resources/favicon.ico";
path.join(this.root, "test/resources/favicon.ico"), }
checkFile await this.#checkRequest(request, response, url, urlParts, pathPart);
);
return;
} }
var disableRangeRequests = this.disableRangeRequests; async #checkRequest(request, response, url, urlParts, pathPart) {
// Check if the file/folder exists.
var filePath; let filePath;
fs.realpath(path.join(this.root, pathPart), checkFile); try {
filePath = await fsPromises.realpath(path.join(this.root, pathPart));
function checkFile(err, file) { } catch {
if (err) { response.writeHead(404);
res.writeHead(404); response.end();
res.end(); if (this.verbose) {
if (verbose) { console.error(`${url}: not found`);
console.error(url + ": not found");
} }
return; return;
} }
filePath = file;
fs.stat(filePath, statFile);
}
var fileSize; // Get the properties of the file/folder.
let stats;
function statFile(err, stats) { try {
if (err) { stats = await fsPromises.stat(filePath);
res.writeHead(500); } catch {
res.end(); response.writeHead(500);
response.end();
return; return;
} }
const fileSize = stats.size;
const isDir = stats.isDirectory();
fileSize = stats.size; // If a folder is requested, serve the directory listing.
var isDir = stats.isDirectory();
if (isDir && !/\/$/.test(pathPart)) { if (isDir && !/\/$/.test(pathPart)) {
res.setHeader("Location", pathPart + "/" + urlParts[2]); response.setHeader("Location", `${pathPart}/${urlParts[2]}`);
res.writeHead(301); response.writeHead(301);
res.end("Redirected", "utf8"); response.end("Redirected", "utf8");
return; return;
} }
if (isDir) { if (isDir) {
self.#serveDirectoryIndex(res, pathPart, queryPart, filePath); const queryPart = urlParts[3];
await this.#serveDirectoryIndex(response, pathPart, queryPart, filePath);
return; return;
} }
var range = req.headers.range; // If a file is requested with range requests, serve it accordingly.
if (range && !disableRangeRequests) { const { range } = request.headers;
var rangesMatches = /^bytes=(\d+)-(\d+)?/.exec(range); if (range && !this.disableRangeRequests) {
const rangesMatches = /^bytes=(\d+)-(\d+)?/.exec(range);
if (!rangesMatches) { if (!rangesMatches) {
res.writeHead(501); response.writeHead(501);
res.end("Bad range", "utf8"); response.end("Bad range", "utf8");
if (verbose) { if (this.verbose) {
console.error(url + ': bad range: "' + range + '"'); console.error(`${url}: bad range: ${range}`);
} }
return; return;
} }
var start = +rangesMatches[1];
var end = +rangesMatches[2]; const start = +rangesMatches[1];
if (verbose) { const end = +rangesMatches[2];
console.log(url + ": range " + start + " - " + end); if (this.verbose) {
console.log(`${url}: range ${start}-${end}`);
} }
self.#serveFileRange( this.#serveFileRange(
res, response,
filePath, filePath,
fileSize, fileSize,
start, start,
@ -192,11 +186,12 @@ class WebServer {
); );
return; return;
} }
if (verbose) {
// Otherwise, serve the file normally.
if (this.verbose) {
console.log(url); console.log(url);
} }
self.#serveFile(res, filePath, fileSize); this.#serveFile(response, filePath, fileSize);
}
} }
async #serveDirectoryIndex(response, pathPart, queryPart, directory) { async #serveDirectoryIndex(response, pathPart, queryPart, directory) {
@ -301,13 +296,10 @@ class WebServer {
response.end(); response.end();
}); });
const extension = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[extension] || defaultMimeType;
if (!this.disableRangeRequests) { if (!this.disableRangeRequests) {
response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Accept-Ranges", "bytes");
} }
response.setHeader("Content-Type", contentType); response.setHeader("Content-Type", this.#getContentType(filePath));
response.setHeader("Content-Length", fileSize); response.setHeader("Content-Length", fileSize);
if (this.cacheExpirationTime > 0) { if (this.cacheExpirationTime > 0) {
const expireTime = new Date(); const expireTime = new Date();
@ -329,11 +321,8 @@ class WebServer {
response.end(); response.end();
}); });
const extension = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[extension] || defaultMimeType;
response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Type", contentType); response.setHeader("Content-Type", this.#getContentType(filePath));
response.setHeader("Content-Length", end - start); response.setHeader("Content-Length", end - start);
response.setHeader( response.setHeader(
"Content-Range", "Content-Range",
@ -342,19 +331,24 @@ class WebServer {
response.writeHead(206); response.writeHead(206);
stream.pipe(response); stream.pipe(response);
} }
#getContentType(filePath) {
const extension = path.extname(filePath).toLowerCase();
return MIME_TYPES[extension] || DEFAULT_MIME_TYPE;
}
} }
// This supports the "Cross-origin" test in test/unit/api_spec.js // This supports the "Cross-origin" test in test/unit/api_spec.js
// It is here instead of test.js so that when the test will still complete as // It is here instead of test.js so that when the test will still complete as
// expected if the user does "gulp server" and then visits // expected if the user does "gulp server" and then visits
// http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin // http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin
function crossOriginHandler(req, res) { function crossOriginHandler(request, response) {
if (req.url === "/test/pdfs/basicapi.pdf?cors=withCredentials") { if (request.url === "/test/pdfs/basicapi.pdf?cors=withCredentials") {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin); response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Allow-Credentials", "true");
} }
if (req.url === "/test/pdfs/basicapi.pdf?cors=withoutCredentials") { if (request.url === "/test/pdfs/basicapi.pdf?cors=withoutCredentials") {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin); response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
} }
} }