Skip to content

Commit 03f04fd

Browse files
committed
replace express-static-gzip with gunzipping ver.
express-static-gzip doesn't gunzip gzipped files for clients that don't have 'Accept-Encoding: gzip', so it has been replaced by a simple clone that does the same thing as the original module and also uses zlib to gunzip the .gz file. This middleware is compatible with files that aren't .gz in the rootFolder, although it will not gzip them on the fly (to my knowledge).
1 parent 28c9361 commit 03f04fd

File tree

4 files changed

+245
-17
lines changed

4 files changed

+245
-17
lines changed

packages/server/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@
1212
"@coder/nbin": "^1.1.2",
1313
"commander": "^2.19.0",
1414
"express": "^4.16.4",
15-
"express-static-gzip": "^1.1.3",
1615
"httpolyglot": "^0.1.2",
17-
"mime-types": "^2.1.21",
16+
"mime-types": "^2.1.24",
1817
"node-netstat": "^1.6.0",
1918
"pem": "^1.14.1",
2019
"promise.prototype.finally": "^3.1.0",
2120
"safe-compare": "^1.1.4",
21+
"serve-static": "^1.14.1",
2222
"ws": "^6.1.2",
2323
"xhr2": "^0.1.4"
2424
},
2525
"devDependencies": {
2626
"@types/commander": "^2.12.2",
27+
"@types/etag": "^1.8.0",
2728
"@types/express": "^4.16.0",
2829
"@types/fs-extra": "^5.0.4",
2930
"@types/mime-types": "^2.1.0",

packages/server/src/server.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { TunnelCloseCode } from "@coder/tunnel/src/common";
55
import { handle as handleTunnel } from "@coder/tunnel/src/server";
66
import * as express from "express";
77
//@ts-ignore
8-
import * as expressStaticGzip from "express-static-gzip";
98
import * as fs from "fs";
109
import { mkdirp } from "fs-extra";
1110
import * as http from "http";
@@ -22,6 +21,7 @@ import * as url from "url";
2221
import * as ws from "ws";
2322
import { buildDir } from "./constants";
2423
import { createPortScanner } from "./portScanner";
24+
import { staticGzip } from "./static";
2525
import safeCompare = require("safe-compare");
2626

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

213-
const baseDir = buildDir || path.join(__dirname, "..");
214-
const staticGzip = expressStaticGzip(path.join(baseDir, "build/web"));
215-
216213
app.use((req, res, next) => {
217214
logger.trace(`\u001B[1m${req.method} ${res.statusCode} \u001B[0m${req.originalUrl}`, field("host", req.hostname), field("ip", req.ip));
218215

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

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

330328
return {
331329
express: app,

packages/server/src/static.ts

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { field, logger } from "@coder/logger";
2+
import * as getETag from "etag";
3+
import * as express from "express";
4+
import * as fs from "fs";
5+
import * as mime from "mime-types";
6+
import * as path from "path";
7+
import * as serveStatic from "serve-static";
8+
import * as zlib from "zlib";
9+
10+
const defaultContentType = "application/octet-stream";
11+
12+
interface FileData {
13+
readonly etag: string;
14+
readonly lastModified: Date;
15+
}
16+
17+
const listGzFilesRecursive = (rootFolder: string, subFolder: string): { [s: string]: FileData } => {
18+
let files: { [s: string]: FileData } = {};
19+
20+
const fullDir = path.join(rootFolder, subFolder);
21+
const contents = fs.readdirSync(fullDir);
22+
for (let file of contents) {
23+
let filePath = path.join(subFolder, file);
24+
const fileFullPath = path.join(fullDir, file);
25+
26+
const stats = fs.statSync(fileFullPath);
27+
if (stats.isDirectory()) {
28+
files = { ...files, ...listGzFilesRecursive(rootFolder, filePath) };
29+
} else if (filePath.endsWith(".gz")) {
30+
try {
31+
filePath = filePath.replace(/\.gz$/i, "");
32+
const etag = getETag(stats);
33+
const mtime = Math.round(stats.mtime/1000)*1000;
34+
const lastModified = new Date(mtime);
35+
files[filePath] = { etag, lastModified };
36+
} catch (err) {
37+
logger.warn("failed to stat file in listGzFilesRecursive", field("filePath", fileFullPath), field("error", err.message));
38+
}
39+
}
40+
}
41+
42+
return files;
43+
};
44+
45+
const setPath = (req: express.Request, path: string): void => {
46+
let query = req.url.split("?").splice(1).join("?");
47+
if (query !== "") {
48+
query = "?" + query;
49+
}
50+
req.url = path + query;
51+
};
52+
53+
// staticGzip returns middleware that serves pre-gzipped files from rootFolder.
54+
// If the client doesn't support gzipped responses, the file will be
55+
// gunzipped. After initializing staticGzip, files in rootFolder shouldn't be
56+
// modified (and new files shouldn't be added) as changes won't be reflected in
57+
// responses.
58+
export const staticGzip = (rootFolder: string): express.RequestHandler => {
59+
if (!fs.existsSync(rootFolder)) {
60+
throw new Error("staticGzip: rootFolder does not exist");
61+
}
62+
63+
const expressStatic = serveStatic(rootFolder);
64+
const gzipFiles = listGzFilesRecursive(rootFolder, "/");
65+
66+
return (req: express.Request, res: express.Response, next: express.NextFunction): void => {
67+
if (req.method !== "GET" && req.method !== "HEAD") {
68+
return next();
69+
}
70+
71+
if (req.path.endsWith("/")) {
72+
setPath(req, req.path + "index.html");
73+
}
74+
75+
// Check for 404, and let expressStatic handle it if it is. This
76+
// also allows for clients to download raw .gz files or files that aren't gzipped.
77+
if (!gzipFiles.hasOwnProperty(req.path)) {
78+
return expressStatic(req, res, next);
79+
}
80+
81+
// If we're going to try serving a gzip using serve-static, we
82+
// need to remove the 'Range' header as you can't gunzip partial
83+
// gzips. Unfourtunately, 'Accept-Ranges' is still returned in serve-static responses.
84+
req.headers["range"] = "";
85+
86+
let contentType = mime.contentType(path.extname(req.path));
87+
if (contentType === false) {
88+
contentType = defaultContentType;
89+
}
90+
res.setHeader("Content-Type", contentType);
91+
res.setHeader("Vary", "Accept-Encoding");
92+
93+
// Send .gz file directly from disk.
94+
if (req.acceptsEncodings("gzip")) {
95+
setPath(req, req.path + ".gz");
96+
res.setHeader("Content-Encoding", "gzip");
97+
98+
return expressStatic(req, res, next);
99+
}
100+
101+
const filePath = path.join(rootFolder, req.path + ".gz");
102+
const fileData = gzipFiles[req.path];
103+
104+
// Set 'ETag' and 'Last-Modified' headers.
105+
res.setHeader("ETag", fileData.etag);
106+
res.setHeader("Last-Modified", fileData.lastModified.toUTCString());
107+
108+
// Try to send 304 Not Modified response by checking 'If-Match'
109+
// and 'If-Modified-Since' headers.
110+
const ifMatch = req.get("if-match");
111+
const ifModifiedSince = req.get("if-modified-since");
112+
if (ifMatch === fileData.etag) {
113+
res.status(304); // Not Modified
114+
115+
return res.end();
116+
}
117+
if (ifModifiedSince) {
118+
const ifModifiedSinceDate = new Date(ifModifiedSince);
119+
if (fileData.lastModified.getTime() <= ifModifiedSinceDate.getTime()) {
120+
res.status(304); // Not Modified
121+
122+
return res.end();
123+
}
124+
}
125+
126+
// Gunzip and return stream. We don't know the resulting
127+
// filesize, so we don't set 'Content-Length'.
128+
try {
129+
const file = fs.createReadStream(filePath);
130+
const gunzip = zlib.createGunzip();
131+
const stream = gunzip.pipe(res);
132+
stream.on("end", () => {
133+
file.close();
134+
gunzip.close();
135+
res.end();
136+
});
137+
file.pipe(gunzip);
138+
} catch (err) {
139+
next(err);
140+
}
141+
};
142+
};

0 commit comments

Comments
 (0)