|
| 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