Merge pull request #17684 from timvandermeij/modernize-webserver-pt2
Modernize the webserver code (part 2)
This commit is contained in:
commit
8487c67cb8
@ -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();
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,132 +67,131 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]}`);
|
||||||
|
response.writeHead(301);
|
||||||
|
response.end("Redirected", "utf8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDir) {
|
||||||
|
const queryPart = urlParts[3];
|
||||||
|
await this.#serveDirectoryIndex(response, pathPart, queryPart, filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = +rangesMatches[1];
|
||||||
|
const end = +rangesMatches[2];
|
||||||
|
if (this.verbose) {
|
||||||
|
console.log(`${url}: range ${start}-${end}`);
|
||||||
|
}
|
||||||
|
this.#serveFileRange(
|
||||||
|
response,
|
||||||
|
filePath,
|
||||||
|
fileSize,
|
||||||
|
start,
|
||||||
|
isNaN(end) ? fileSize : end + 1
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var disableRangeRequests = this.disableRangeRequests;
|
// Otherwise, serve the file normally.
|
||||||
|
if (this.verbose) {
|
||||||
var filePath;
|
console.log(url);
|
||||||
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;
|
|
||||||
}
|
|
||||||
filePath = file;
|
|
||||||
fs.stat(filePath, statFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileSize;
|
|
||||||
|
|
||||||
function statFile(err, stats) {
|
|
||||||
if (err) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user