Skip to content

Replace express-static-gzip with a gunzipping version #826

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

Closed
wants to merge 1 commit into from
Closed
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
5 changes: 3 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@
"@coder/nbin": "^1.1.2",
"commander": "^2.19.0",
"express": "^4.16.4",
"express-static-gzip": "^1.1.3",
"httpolyglot": "^0.1.2",
"mime-types": "^2.1.21",
"mime-types": "^2.1.24",
"node-netstat": "^1.6.0",
"pem": "^1.14.1",
"promise.prototype.finally": "^3.1.0",
"safe-compare": "^1.1.4",
"serve-static": "^1.14.1",
"ws": "^6.1.2",
"xhr2": "^0.1.4"
},
"devDependencies": {
"@types/commander": "^2.12.2",
"@types/etag": "^1.8.0",
"@types/express": "^4.16.0",
"@types/fs-extra": "^5.0.4",
"@types/mime-types": "^2.1.0",
Expand Down
8 changes: 3 additions & 5 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TunnelCloseCode } from "@coder/tunnel/src/common";
import { handle as handleTunnel } from "@coder/tunnel/src/server";
import * as express from "express";
//@ts-ignore
import * as expressStaticGzip from "express-static-gzip";
import * as fs from "fs";
import { mkdirp } from "fs-extra";
import * as http from "http";
Expand All @@ -22,6 +21,7 @@ import * as url from "url";
import * as ws from "ws";
import { buildDir } from "./constants";
import { createPortScanner } from "./portScanner";
import { staticGzip } from "./static";
import safeCompare = require("safe-compare");

interface CreateAppOptions {
Expand Down Expand Up @@ -210,9 +210,6 @@ export const createApp = async (options: CreateAppOptions): Promise<{
return res.redirect(code, newUrlString);
};

const baseDir = buildDir || path.join(__dirname, "..");
const staticGzip = expressStaticGzip(path.join(baseDir, "build/web"));

app.use((req, res, next) => {
logger.trace(`\u001B[1m${req.method} ${res.statusCode} \u001B[0m${req.originalUrl}`, field("host", req.hostname), field("ip", req.ip));

Expand Down Expand Up @@ -325,7 +322,8 @@ export const createApp = async (options: CreateAppOptions): Promise<{
});

// Everything else just pulls from the static build directory.
app.use(staticGzip);
const baseDir = buildDir || path.join(__dirname, "..");
app.use(staticGzip(path.join(baseDir, "build/web")));

return {
express: app,
Expand Down
142 changes: 142 additions & 0 deletions packages/server/src/static.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { field, logger } from "@coder/logger";
import * as getETag from "etag";
import * as express from "express";
import * as fs from "fs";
import * as mime from "mime-types";
import * as path from "path";
import * as serveStatic from "serve-static";
import * as zlib from "zlib";

const defaultContentType = "application/octet-stream";

interface FileData {
readonly etag: string;
readonly lastModified: Date;
}

const listGzFilesRecursive = (rootFolder: string, subFolder: string): { [s: string]: FileData } => {
let files: { [s: string]: FileData } = {};

const fullDir = path.join(rootFolder, subFolder);
const contents = fs.readdirSync(fullDir);
for (let file of contents) {
let filePath = path.join(subFolder, file);
const fileFullPath = path.join(fullDir, file);

const stats = fs.statSync(fileFullPath);
if (stats.isDirectory()) {
files = { ...files, ...listGzFilesRecursive(rootFolder, filePath) };
} else if (filePath.endsWith(".gz")) {
try {
filePath = filePath.replace(/\.gz$/i, "");
const etag = getETag(stats);
const mtime = Math.round(stats.mtime/1000)*1000;
const lastModified = new Date(mtime);
files[filePath] = { etag, lastModified };
} catch (err) {
logger.warn("failed to stat file in listGzFilesRecursive", field("filePath", fileFullPath), field("error", err.message));
}
}
}

return files;
};

const setPath = (req: express.Request, path: string): void => {
let query = req.url.split("?").splice(1).join("?");
if (query !== "") {
query = "?" + query;
}
req.url = path + query;
};

// staticGzip returns middleware that serves pre-gzipped files from rootFolder.
// If the client doesn't support gzipped responses, the file will be
// gunzipped. After initializing staticGzip, files in rootFolder shouldn't be
// modified (and new files shouldn't be added) as changes won't be reflected in
// responses.
export const staticGzip = (rootFolder: string): express.RequestHandler => {
if (!fs.existsSync(rootFolder)) {
throw new Error("staticGzip: rootFolder does not exist");
}

const expressStatic = serveStatic(rootFolder);
const gzipFiles = listGzFilesRecursive(rootFolder, "/");

return (req: express.Request, res: express.Response, next: express.NextFunction): void => {
if (req.method !== "GET" && req.method !== "HEAD") {
return next();
}

if (req.path.endsWith("/")) {
setPath(req, req.path + "index.html");
}

// Check for 404, and let expressStatic handle it if it is. This
// also allows for clients to download raw .gz files or files that aren't gzipped.
if (!gzipFiles.hasOwnProperty(req.path)) {
return expressStatic(req, res, next);
}

// If we're going to try serving a gzip using serve-static, we
// need to remove the 'Range' header as you can't gunzip partial
// gzips. Unfourtunately, 'Accept-Ranges' is still returned in serve-static responses.
req.headers["range"] = "";

let contentType = mime.contentType(path.extname(req.path));
if (contentType === false) {
contentType = defaultContentType;
}
res.setHeader("Content-Type", contentType);
res.setHeader("Vary", "Accept-Encoding");

// Send .gz file directly from disk.
if (req.acceptsEncodings("gzip")) {
setPath(req, req.path + ".gz");
res.setHeader("Content-Encoding", "gzip");

return expressStatic(req, res, next);
}

const filePath = path.join(rootFolder, req.path + ".gz");
const fileData = gzipFiles[req.path];

// Set 'ETag' and 'Last-Modified' headers.
res.setHeader("ETag", fileData.etag);
res.setHeader("Last-Modified", fileData.lastModified.toUTCString());

// Try to send 304 Not Modified response by checking 'If-Match'
// and 'If-Modified-Since' headers.
const ifMatch = req.get("if-match");
const ifModifiedSince = req.get("if-modified-since");
if (ifMatch === fileData.etag) {
res.status(304); // Not Modified

return res.end();
}
if (ifModifiedSince) {
const ifModifiedSinceDate = new Date(ifModifiedSince);
if (fileData.lastModified.getTime() <= ifModifiedSinceDate.getTime()) {
res.status(304); // Not Modified

return res.end();
}
}

// Gunzip and return stream. We don't know the resulting
// filesize, so we don't set 'Content-Length'.
try {
const file = fs.createReadStream(filePath);
const gunzip = zlib.createGunzip();
const stream = gunzip.pipe(res);
stream.on("end", () => {
file.close();
gunzip.close();
res.end();
});
file.pipe(gunzip);
} catch (err) {
next(err);
}
};
};
Loading