Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modernize the webserver code (part 2) #17684

Merged
merged 4 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions gulpfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2068,8 +2068,7 @@ gulp.task(
console.log("### Starting local server");

const { WebServer } = await import("./test/webserver.mjs");
const server = new WebServer();
server.port = 8888;
const server = new WebServer({ port: 8888 });
server.start();
}
)
Expand Down
11 changes: 6 additions & 5 deletions test/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,11 +1015,12 @@ async function startBrowsers({ baseUrl, initializeSession }) {
}

function startServer() {
server = new WebServer();
server.host = host;
server.port = options.port;
server.root = "..";
server.cacheExpirationTime = 3600;
server = new WebServer({
root: "..",
host,
port: options.port,
cacheExpirationTime: 3600,
});
server.start();
}

Expand Down
222 changes: 108 additions & 114 deletions test/webserver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-var */

import fs from "fs";
import fsPromises from "fs/promises";
import http from "http";
import path from "path";

var mimeTypes = {
const MIME_TYPES = {
".css": "text/css",
".html": "text/html",
".js": "application/javascript",
Expand All @@ -36,17 +35,16 @@ var mimeTypes = {
".bcmap": "application/octet-stream",
".ftl": "text/plain",
};

var defaultMimeType = "application/octet-stream";
const DEFAULT_MIME_TYPE = "application/octet-stream";

class WebServer {
constructor() {
this.root = ".";
this.host = "localhost";
this.port = 0;
constructor({ root, host, port, cacheExpirationTime }) {
this.root = root || ".";
this.host = host || "localhost";
this.port = port || 0;
this.server = null;
this.verbose = false;
this.cacheExpirationTime = 0;
this.cacheExpirationTime = cacheExpirationTime || 0;
this.disableRangeRequests = false;
this.hooks = {
GET: [crossOriginHandler],
Expand All @@ -58,9 +56,7 @@ class WebServer {
this.#ensureNonZeroPort();
this.server = http.createServer(this.#handler.bind(this));
this.server.listen(this.port, this.host, callback);
console.log(
"Server running at http://" + this.host + ":" + this.port + "/"
);
console.log(`Server running at http://${this.host}:${this.port}/`);
}

stop(callback) {
Expand All @@ -71,132 +67,131 @@ class WebServer {
#ensureNonZeroPort() {
if (!this.port) {
// 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().
var server = http.createServer().listen(0);
var address = server.address();
// .address().port being available synchronously is merely an
// implementation detail. So we are defensive here and fall back to some
// name to make sure that the port is synchronously set by `.listen()`.
const server = http.createServer().listen(0);
const address = server.address();
// `.address().port` being available synchronously is merely an
// implementation detail, so we are defensive here and fall back to a
// fixed port when the address is not available yet.
this.port = address ? address.port : 8000;
server.close();
}
}

#handler(req, res) {
var self = this;
var url = req.url.replaceAll("//", "/");
var urlParts = /([^?]*)((?:\?(.*))?)/.exec(url);
async #handler(request, response) {
// Validate and parse the request URL.
const url = request.url.replaceAll("//", "/");
const urlParts = /([^?]*)((?:\?(.*))?)/.exec(url);
let pathPart;
try {
// Guard against directory traversal attacks such as
// `/../../../../../../../etc/passwd`, which let you make GET requests
// for files outside of `this.root`.
var pathPart = path.normalize(decodeURI(urlParts[1]));
// path.normalize returns a path on the basis of the current platform.
// Windows paths cause issues in statFile and serverDirectoryIndex.
// Converting to unix path would avoid platform checks in said functions.
pathPart = path.normalize(decodeURI(urlParts[1]));
// `path.normalize` returns a path on the basis of the current platform.
// Windows paths cause issues in `checkRequest` and underlying methods.
// Converting to a Unix path avoids platform checks in said functions.
pathPart = pathPart.replaceAll("\\", "/");
} catch {
// 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
// handled as a bad request.
res.writeHead(400);
res.end("Bad request", "utf8");
response.writeHead(400);
response.end("Bad request", "utf8");
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) {
res.writeHead(405);
res.end("Unsupported request method", "utf8");
response.writeHead(405);
response.end("Unsupported request method", "utf8");
return;
}
var handled = methodHooks.some(function (hook) {
return hook(req, res);
});
const handled = methodHooks.some(hook => hook(request, response));
if (handled) {
return;
}

// Check the request and serve the file/folder contents.
if (pathPart === "/favicon.ico") {
fs.realpath(
path.join(this.root, "test/resources/favicon.ico"),
checkFile
);
return;
pathPart = "test/resources/favicon.ico";
timvandermeij marked this conversation as resolved.
Show resolved Hide resolved
}
await this.#checkRequest(request, response, url, urlParts, pathPart);
}

var disableRangeRequests = this.disableRangeRequests;

var filePath;
fs.realpath(path.join(this.root, pathPart), checkFile);

function checkFile(err, file) {
if (err) {
res.writeHead(404);
res.end();
if (verbose) {
console.error(url + ": not found");
}
return;
async #checkRequest(request, response, url, urlParts, pathPart) {
// Check if the file/folder exists.
let filePath;
try {
filePath = await fsPromises.realpath(path.join(this.root, pathPart));
} catch {
response.writeHead(404);
response.end();
if (this.verbose) {
console.error(`${url}: not found`);
Dismissed Show dismissed Hide dismissed
}
filePath = file;
fs.stat(filePath, statFile);
return;
}

var fileSize;
// Get the properties of the file/folder.
let stats;
try {
stats = await fsPromises.stat(filePath);
} catch {
response.writeHead(500);
response.end();
return;
}
const fileSize = stats.size;
const isDir = stats.isDirectory();

// If a folder is requested, serve the directory listing.
if (isDir && !/\/$/.test(pathPart)) {
response.setHeader("Location", `${pathPart}/${urlParts[2]}`);
Dismissed Show dismissed Hide dismissed
response.writeHead(301);
response.end("Redirected", "utf8");
return;
}
if (isDir) {
const queryPart = urlParts[3];
await this.#serveDirectoryIndex(response, pathPart, queryPart, filePath);
return;
}

function statFile(err, stats) {
if (err) {
res.writeHead(500);
res.end();
// If a file is requested with range requests, serve it accordingly.
const { range } = request.headers;
if (range && !this.disableRangeRequests) {
const rangesMatches = /^bytes=(\d+)-(\d+)?/.exec(range);
if (!rangesMatches) {
response.writeHead(501);
response.end("Bad range", "utf8");
if (this.verbose) {
console.error(`${url}: bad range: ${range}`);
Dismissed Show dismissed Hide dismissed
}
return;
}

fileSize = stats.size;
var isDir = stats.isDirectory();
if (isDir && !/\/$/.test(pathPart)) {
res.setHeader("Location", pathPart + "/" + urlParts[2]);
res.writeHead(301);
res.end("Redirected", "utf8");
return;
}
if (isDir) {
self.#serveDirectoryIndex(res, pathPart, queryPart, filePath);
return;
const start = +rangesMatches[1];
const end = +rangesMatches[2];
if (this.verbose) {
console.log(`${url}: range ${start}-${end}`);
Dismissed Show dismissed Hide dismissed
}
this.#serveFileRange(
response,
filePath,
fileSize,
start,
isNaN(end) ? fileSize : end + 1
);
return;
}

var range = req.headers.range;
if (range && !disableRangeRequests) {
var rangesMatches = /^bytes=(\d+)-(\d+)?/.exec(range);
if (!rangesMatches) {
res.writeHead(501);
res.end("Bad range", "utf8");
if (verbose) {
console.error(url + ': bad range: "' + range + '"');
}
return;
}
var start = +rangesMatches[1];
var end = +rangesMatches[2];
if (verbose) {
console.log(url + ": range " + start + " - " + end);
}
self.#serveFileRange(
res,
filePath,
fileSize,
start,
isNaN(end) ? fileSize : end + 1
);
return;
}
if (verbose) {
console.log(url);
}
self.#serveFile(res, filePath, fileSize);
// Otherwise, serve the file normally.
if (this.verbose) {
console.log(url);
Dismissed Show dismissed Hide dismissed
}
this.#serveFile(response, filePath, fileSize);
}

async #serveDirectoryIndex(response, pathPart, queryPart, directory) {
Expand Down Expand Up @@ -301,13 +296,10 @@ class WebServer {
response.end();
});

const extension = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[extension] || defaultMimeType;

if (!this.disableRangeRequests) {
response.setHeader("Accept-Ranges", "bytes");
}
response.setHeader("Content-Type", contentType);
response.setHeader("Content-Type", this.#getContentType(filePath));
response.setHeader("Content-Length", fileSize);
if (this.cacheExpirationTime > 0) {
const expireTime = new Date();
Expand All @@ -329,11 +321,8 @@ class WebServer {
response.end();
});

const extension = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[extension] || defaultMimeType;

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-Range",
Expand All @@ -342,19 +331,24 @@ class WebServer {
response.writeHead(206);
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
// 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
// http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin
function crossOriginHandler(req, res) {
if (req.url === "/test/pdfs/basicapi.pdf?cors=withCredentials") {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
function crossOriginHandler(request, response) {
if (request.url === "/test/pdfs/basicapi.pdf?cors=withCredentials") {
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
}
if (req.url === "/test/pdfs/basicapi.pdf?cors=withoutCredentials") {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
if (request.url === "/test/pdfs/basicapi.pdf?cors=withoutCredentials") {
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
}
}

Expand Down