From 8064627f27b93eeac5c9d5b187f10815eacacd1c Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Thu, 30 Sep 2021 16:37:06 -0700 Subject: [PATCH] Split Express app setup from server setup Separates their concerns: middleware, routing, handlers, etc. belong to the app while CLI argument parsing, ports, listening, etc. belong to the server. --- server.js | 178 +++++++++-------------------------------------------- src/app.js | 134 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 148 deletions(-) create mode 100644 src/app.js diff --git a/server.js b/server.js index 7c58953ec..e6b098317 100644 --- a/server.js +++ b/server.js @@ -1,19 +1,7 @@ -/* eslint no-console: off */ -const path = require("path"); -const sslRedirect = require('heroku-ssl-redirect'); -const nakedRedirect = require('express-naked-redirect'); -const express = require("express"); -const expressStaticGzip = require("express-static-gzip"); -const favicon = require('serve-favicon'); -const compression = require('compression'); const argparse = require('argparse'); -const utils = require("./src/utils"); -const { potentialAuspiceRoutes, isRequestBackedByAuspiceDataset } = require('./src/auspicePaths'); -const cors = require('cors'); -const {addAsync} = require("@awaitjs/express"); -const {NotFound} = require('http-errors'); +const http = require("http"); -const production = process.env.NODE_ENV === "production"; +const utils = require("./src/utils"); const version = utils.getGitHash(); const nextstrainAbout = ` @@ -33,139 +21,33 @@ parser.addArgument('--verbose', {action: "storeTrue", help: "verbose server logg const args = parser.parseArgs(); global.verbose = args.verbose; - -// Import these after parsing CLI arguments and setting global.verbose so code -// in them can use utils.verbose() at load time. -const auspiceServerHandlers = require("./src/index.js"); -const authn = require("./src/authn"); -const redirects = require("./src/redirects"); - -/* Path helpers for static assets, to make routes more readable. - */ -const relativePath = (...subpath) => - path.join(__dirname, ...subpath); - -const gatsbyAssetPath = (...subpath) => - relativePath("static-site", "public", ...subpath); - -const auspiceAssetPath = (...subpath) => - relativePath("auspice-client", ...subpath); - - -/* BASIC APP SETUP */ -// NOTE: order of app.get is first come first serve (https://stackoverflow.com/questions/32603818/order-of-router-precedence-in-express-js) -const app = addAsync(express()); - -// In production, trust Heroku as a reverse proxy and Express will use request -// metadata from the proxy. -if (production) app.enable("trust proxy"); - -app.set('port', process.env.PORT || 5000); -app.use(sslRedirect()); // redirect HTTP to HTTPS -app.use(compression()); // send files (e.g. res.json()) using compression (if possible) -app.use(nakedRedirect({reverse: true})); // redirect www.nextstrain.org to nextstrain.org -app.use(favicon(relativePath("favicon.ico"))); -app.use('/favicon.png', express.static(relativePath("favicon.png"))); - - -/* Authentication (authn) - */ -authn.setup(app); - - -/* Redirects. - */ -redirects.setup(app); - - -/* Static assets. - * Any paths matching resources here will be handled and not fall through to our handlers below. - * E.g. URL `/influenza` gets sent `static-site/public/influenza/index.html` - */ -app.use(express.static(gatsbyAssetPath())); -app.route("/dist/*") // Auspice hardcodes /dist/… in its Webpack config. - .all(expressStaticGzip(auspiceAssetPath(), {maxAge: '30d'})); - - -/* Charon API used by Auspice. - */ -app.routeAsync("/charon/getAvailable") - .all(cors({origin: 'http://localhost:8000'})) // allow cross-origin from the gatsby dev server - .getAsync(auspiceServerHandlers.getAvailable); - -app.routeAsync("/charon/getDataset") - .getAsync(auspiceServerHandlers.getDataset); - -app.routeAsync("/charon/getNarrative") - .getAsync(auspiceServerHandlers.getNarrative); - -app.routeAsync("/charon/getSourceInfo") - .getAsync(auspiceServerHandlers.getSourceInfo); - -app.routeAsync("/charon/*") - .all((req) => { - utils.warn(`(${req.method}) ${req.url} has not been handled / has no handler`); - throw new NotFound(); - }); - -/* Specific routes to be handled by Gatsby client-side routing - */ -app.route([ - "/users/:user", - "/groups", - "/groups/:groupName" -]).get((req, res) => res.sendFile(gatsbyAssetPath("index.html"))); - -/* Routes which are plausibly for auspice, as they have specific path signatures. - * We verify if these are are backed by datasets and hand to auspice if so. - */ -app.route(potentialAuspiceRoutes).get( - isRequestBackedByAuspiceDataset, - sendAuspiceHandler, - sendGatsbyHandler -); - -/** - * Finally, we catch all remaining routes and send to Gatsby - */ -app.get("*", sendGatsbyHandler); - - -const server = app.listen(app.get('port'), () => { - console.log(" -------------------------------------------------------------------------"); - console.log(nextstrainAbout); - console.log(` Server listening on port ${server.address().port}`); - console.log(` Accessible at https://nextstrain.org or http://localhost:${server.address().port}`); - console.log(` Server is running in ${production ? 'production' : 'development'} mode`); - console.log("\n -------------------------------------------------------------------------\n\n"); -}).on('error', (err) => { - if (err.code === 'EADDRINUSE') { - utils.error(`Port ${app.get('port')} is currently in use by another program. - You must either close that program or specify a different port by setting the shell variable - "$PORT". Note that on MacOS / Linux, "lsof -n -i :${app.get('port')} | grep LISTEN" should - identify the process currently using the port.`); - } - utils.error(`Uncaught error in app.listen(). Code: ${err.code}`); -}); - - -function sendGatsbyHandler(req, res) { - utils.verbose(`Sending Gatsby entrypoint for ${req.originalUrl}`); - return res.sendFile( - gatsbyAssetPath(""), - {}, - (err) => { - // callback runs when the transfer is complete or when an error occurs - if (err) res.status(404).sendFile(gatsbyAssetPath("404.html")); +const port = process.env.PORT || 5000; + +/* Import app after setting global.verbose so that calls to utils.verbose() + * respect our --verbose option as expected. + */ +const app = require("./src/app"); + +app.set("port", port); + +const server = http.createServer(app) + .on("listening", () => { + console.log(" -------------------------------------------------------------------------"); + console.log(nextstrainAbout); + console.log(` Server listening on port ${server.address().port}`); + console.log(` Accessible at https://nextstrain.org or http://localhost:${server.address().port}`); + console.log(` Server is running in ${app.locals.production ? 'production' : 'development'} mode`); + console.log("\n -------------------------------------------------------------------------\n\n"); + }) + .on("error", (err) => { + if (err.code === 'EADDRINUSE') { + utils.error(`Port ${app.get('port')} is currently in use by another program. + You must either close that program or specify a different port by setting the shell variable + "$PORT". Note that on MacOS / Linux, "lsof -n -i :${app.get('port')} | grep LISTEN" should + identify the process currently using the port.`); } - ); -} + utils.error(`Uncaught error in app.listen(). Code: ${err.code}`); + }) +; -function sendAuspiceHandler(req, res, next) { - if (!req.sendToAuspice) return next(); // see previous middleware - utils.verbose(`Sending Auspice entrypoint for ${req.originalUrl}`); - return res.sendFile( - auspiceAssetPath("dist", "index.html"), - {headers: {"Cache-Control": "no-cache, no-store, must-revalidate"}} - ); -} +server.listen(port); diff --git a/src/app.js b/src/app.js new file mode 100644 index 000000000..46ea5c1bc --- /dev/null +++ b/src/app.js @@ -0,0 +1,134 @@ +/* eslint no-console: off */ +const path = require("path"); +const sslRedirect = require('heroku-ssl-redirect'); +const nakedRedirect = require('express-naked-redirect'); +const express = require("express"); +const expressStaticGzip = require("express-static-gzip"); +const favicon = require('serve-favicon'); +const compression = require('compression'); +const utils = require("./utils"); +const { potentialAuspiceRoutes, isRequestBackedByAuspiceDataset } = require('./auspicePaths'); +const cors = require('cors'); +const {addAsync} = require("@awaitjs/express"); +const {NotFound} = require('http-errors'); + +const production = process.env.NODE_ENV === "production"; + +const auspiceServerHandlers = require("./index.js"); +const authn = require("./authn"); +const redirects = require("./redirects"); + +/* Path helpers for static assets, to make routes more readable. + */ +const relativePath = (...subpath) => + path.join(__dirname, "..", ...subpath); + +const gatsbyAssetPath = (...subpath) => + relativePath("static-site", "public", ...subpath); + +const auspiceAssetPath = (...subpath) => + relativePath("auspice-client", ...subpath); + + +/* BASIC APP SETUP */ +// NOTE: order of app.get is first come first serve (https://stackoverflow.com/questions/32603818/order-of-router-precedence-in-express-js) +const app = addAsync(express()); + +app.locals.production = production; + +// In production, trust Heroku as a reverse proxy and Express will use request +// metadata from the proxy. +if (production) app.enable("trust proxy"); + +app.use(sslRedirect()); // redirect HTTP to HTTPS +app.use(compression()); // send files (e.g. res.json()) using compression (if possible) +app.use(nakedRedirect({reverse: true})); // redirect www.nextstrain.org to nextstrain.org +app.use(favicon(relativePath("favicon.ico"))); +app.use('/favicon.png', express.static(relativePath("favicon.png"))); + + +/* Authentication (authn) + */ +authn.setup(app); + + +/* Redirects. + */ +redirects.setup(app); + + +/* Static assets. + * Any paths matching resources here will be handled and not fall through to our handlers below. + * E.g. URL `/influenza` gets sent `static-site/public/influenza/index.html` + */ +app.use(express.static(gatsbyAssetPath())); +app.route("/dist/*") // Auspice hardcodes /dist/… in its Webpack config. + .all(expressStaticGzip(auspiceAssetPath(), {maxAge: '30d'})); + + +/* Charon API used by Auspice. + */ +app.routeAsync("/charon/getAvailable") + .all(cors({origin: 'http://localhost:8000'})) // allow cross-origin from the gatsby dev server + .getAsync(auspiceServerHandlers.getAvailable); + +app.routeAsync("/charon/getDataset") + .getAsync(auspiceServerHandlers.getDataset); + +app.routeAsync("/charon/getNarrative") + .getAsync(auspiceServerHandlers.getNarrative); + +app.routeAsync("/charon/getSourceInfo") + .getAsync(auspiceServerHandlers.getSourceInfo); + +app.routeAsync("/charon/*") + .all((req) => { + utils.warn(`(${req.method}) ${req.url} has not been handled / has no handler`); + throw new NotFound(); + }); + +/* Specific routes to be handled by Gatsby client-side routing + */ +app.route([ + "/users/:user", + "/groups", + "/groups/:groupName" +]).get((req, res) => res.sendFile(gatsbyAssetPath("index.html"))); + +/* Routes which are plausibly for auspice, as they have specific path signatures. + * We verify if these are are backed by datasets and hand to auspice if so. + */ +app.route(potentialAuspiceRoutes).get( + isRequestBackedByAuspiceDataset, + sendAuspiceHandler, + sendGatsbyHandler +); + +/** + * Finally, we catch all remaining routes and send to Gatsby + */ +app.get("*", sendGatsbyHandler); + + +function sendGatsbyHandler(req, res) { + utils.verbose(`Sending Gatsby entrypoint for ${req.originalUrl}`); + return res.sendFile( + gatsbyAssetPath(""), + {}, + (err) => { + // callback runs when the transfer is complete or when an error occurs + if (err) res.status(404).sendFile(gatsbyAssetPath("404.html")); + } + ); +} + +function sendAuspiceHandler(req, res, next) { + if (!req.sendToAuspice) return next(); // see previous middleware + utils.verbose(`Sending Auspice entrypoint for ${req.originalUrl}`); + return res.sendFile( + auspiceAssetPath("dist", "index.html"), + {headers: {"Cache-Control": "no-cache, no-store, must-revalidate"}} + ); +} + +module.exports = app;